Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9d80c1cda
|
||
|
|
06bc7da07d
|
||
|
|
2fd3a1ad38
|
||
|
|
4753174e1b | ||
|
|
50bf08f32a
|
||
|
|
024cda7b08
|
||
|
|
81f9917e10 | ||
|
|
bb556d9b0c | ||
|
|
7bf8f6c804 |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
|
||||
@@ -10,6 +10,7 @@ Requires QQQ 4.1.0-RC.6 or later (the spec-managed UI API extension contract).
|
||||
- Real-time indexing failures now increase the table's error count by the number of failed events. Successful basepulls and full reindexes reset the count. Counter update failures are logged without failing the source write.
|
||||
|
||||
### Added
|
||||
- Session-only typeahead: `GET /qqq/v1/qbit/quick-search/suggest` (`getQuickSearchSuggestions`) and `QuickSearchSuggestService.suggest(...)`. Reuses the permission-aware search action with one bounded candidate batch per table; groups expose only record IDs, filtered labels/subtitles, `hasMore` and `truncated`. Defaults to 5 items across 8 tables (maximum 10/20); short terms and unavailable search return empty groups. The existing UI extension and generated contract now carry both status and suggestions.
|
||||
- Supported host status API (`api.QuickSearchStatusService.get()`), immutable status values, and `QuickSearchApi` home navigation constants (issue #50/F10). Permission-checked, bounded diagnostics refreshes share a short per-runtime cache and reuse saved validation counts. New spec-managed `GET /qqq/v1/qbit/quick-search/status` (`getQuickSearchStatus`) with committed OpenAPI fragment and strict HTTP/contract tests. RC.6 requires the default `/qbit/quick-search` prefix; `/quick-search` is not permitted by core. Existing routes and wire shapes are unchanged.
|
||||
- Record developer panel: `QuickSearchDeveloperPanelProvider` (id `quickSearch`) adds a "Quick Search" panel to the developer view of every record of an indexed table (`/app/{table}/{id}/dev` in the Next UI), for users who may read Quick Search Index. It shows the table's index row (enabled, status, record count, last basepull and full reindex, searchable fields), the latest index run, the record's indexed document compared field by field with the database (Inspect Search Document's comparison; needs `debugPermissionRules`), the record's failed index events, the Inspect Search Document and Reindex in Search actions the user may run, and links to the admin app and the index row. An unreachable OpenSearch shows a warning in the panel. Registered by `QuickSearchDeveloperPanelsMetaDataProducer`.
|
||||
|
||||
@@ -20,6 +21,8 @@ Requires QQQ 4.1.0-RC.6 or later (the spec-managed UI API extension contract).
|
||||
- Possible-value sources `quick_search_eligible_table`, `quick_search_indexable_field`, `quick_search_timestamp_field`, `quick_search_field_role`, `quick_search_field_analyzer`, `quick_search_documents_choice`, `quick_search_config_source`; the join `quick_search_index_join_quick_search_index_field` and the Searchable Fields widget on the index record. Config tables are audited at field level.
|
||||
|
||||
### Changed
|
||||
- Distinguish protected fields from fields that simply cannot be searched. Boolean, blob and hidden fields no longer trigger result rebuilding or erase record labels. Labels are suppressed only when their source fields are protected; unprotected hidden primary keys and timestamps remain usable.
|
||||
- Exclude PII/PHI-classified fields (including table defaults) and field-security-locked fields everywhere Quick Search indexes or displays values. Configuration rejects protected fields; code defaults drop them with one warning per table. Search and diagnostics omit protected values from older documents. Rebuild existing indexes after upgrading or changing classification metadata. Uses QQQ 4.1.0-RC.6; no dependency changes.
|
||||
- `quick_search_index`: the generic Create and Delete are off (`TABLE_INSERT`, `TABLE_DELETE`); use Add Search Index and Remove Search Index. `quick_search_searchable_table` is now a custom source over the effective tables. `quick_search_index_run` gains `cancelRequested` and `cancelRequestedBy`.
|
||||
- Hidden and `isSensitive` fields are never indexed (previously hidden and password only).
|
||||
- Record screens (record-screen audit): `quick_search_index_run` and `quick_search_failed_event` are written by the QBit only, so Create and Edit are off (Delete stays, for clearing); both get named field sections, record labels and pickers (`quick_search_run_type`, `quick_search_run_status`, `quick_search_failed_event_status`, `quick_search_failed_event_action`, and the table picker for `tableName`). The index record lists its Recent Runs (join `quick_search_index_join_quick_search_index_run`). `quick_search_validation_run.reportJson` uses a JSON code editor. The Basepull, Full Reindex and Reconcile processes run from Quick Search Index's Actions and are no longer app (nav) children; their table input is `searchTableName` (`tableName` still works for API callers, and a Quick Search table name is ignored), so running them from the table no longer limits them to `quick_search_index`. Process input steps now carry `HELP_TEXT` and `EDIT_FORM` components, so the Next UI renders their fields.
|
||||
|
||||
@@ -291,7 +291,55 @@ See also the [QQQ adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
|
||||
|
||||
Apache License, Version 2.0. See [LICENSE](LICENSE).
|
||||
|
||||
## Status API for hosts
|
||||
## UI API for hosts
|
||||
|
||||
The `quick-search` extension registers both status and suggestions in the committed
|
||||
[OpenAPI fragment](src/main/resources/openapi/qqq-v1/quick-search.yaml).
|
||||
|
||||
### Typeahead suggestions
|
||||
|
||||
`GET /qqq/v1/qbit/quick-search/suggest` (`getQuickSearchSuggestions`) requires a
|
||||
signed-in session. Quick Search admin permission is not required. Java hosts use
|
||||
`QuickSearchSuggestService.suggest(q, limitPerTable, maxTables, tables)` in the caller's
|
||||
`QContext`.
|
||||
|
||||
| Query parameter | Meaning |
|
||||
| --- | --- |
|
||||
| `q` | Required text, trimmed with consecutive whitespace collapsed. Fewer than 2 characters returns empty groups; more than 100 returns HTTP 400. |
|
||||
| `limitPerTable` | Integer 1..10; default 5. |
|
||||
| `maxTables` | Integer 1..20; default 8. |
|
||||
| `tables` | Optional comma-separated table metadata names. Unknown, disabled and unreadable tables are omitted; an empty filter returns no groups. |
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "blue wid",
|
||||
"groups": [{
|
||||
"tableName": "product",
|
||||
"tableLabel": "Products",
|
||||
"hasMore": true,
|
||||
"items": [{"recordId": "42", "label": "Blue Widget", "subtitle": "Small"}]
|
||||
}],
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
The existing edge-ngram query supports the last word's prefix. Groups are ordered
|
||||
by their best accessible score, with per-table relevance order preserved. Labels
|
||||
fall back to `tableLabel #recordId`; subtitles are nullable and come only from the
|
||||
already-filtered result. Scores, highlights and display values are never returned.
|
||||
The existing action enforces table READ permissions, enabled-table selection,
|
||||
configured record security locks (on by default), PHI/PII exclusions and protected
|
||||
primary keys.
|
||||
|
||||
Suggestions make one bounded OpenSearch request per eligible table, fetching at
|
||||
most `2 * (limitPerTable + 1)` candidates under record locks (also subject to the
|
||||
host's `maxSearchLimit`). Locked rows can leave a group partially filled.
|
||||
`hasMore` confirms an extra accessible hit in this batch; false does not prove
|
||||
there are no further matches. `truncated` means more tables had accessible hits
|
||||
than `maxTables`. Missing runtime/client or unavailable search returns empty groups.
|
||||
Missing `q`, invalid integers and out-of-range limits return HTTP 400.
|
||||
|
||||
### Administrative status
|
||||
|
||||
Requires QQQ **4.1.0-RC.6**. Hosts should use the supported API rather than reach into
|
||||
`QuickSearchRuntime`, `QuickSearchOpenSearchClient`, diagnostic renderers, or qbit tables:
|
||||
@@ -346,3 +394,36 @@ configured metadata prefix. `QuickSearchApi.APP_NAME` (`quickSearchAdmin`) and
|
||||
`QuickSearchApi.HOME_HREF` (`/app/quickSearchAdmin`) are available for default-name
|
||||
navigation. Keep detailed runs, failed events, validation and operator tools in the
|
||||
qbit's own home. [kof22 migration mapping](docs/STATUS-API-HOST-MIGRATION.md).
|
||||
|
||||
## Classified data
|
||||
|
||||
Quick Search excludes fields classified as **PII** or **PHI**, including fields
|
||||
that inherit the table's classification. It also excludes hidden, sensitive,
|
||||
password/masked fields and every field with a `FieldSecurityLock`; a shared
|
||||
search index cannot enforce access to individual fields for each user. PUBLIC,
|
||||
CONFIDENTIAL and unclassified fields remain eligible under the usual field rules.
|
||||
|
||||
The same rule applies to annotation and code configuration, admin field choices,
|
||||
real-time writes, basepull, rebuilds, replay, labels, snippets, analyzer copies and
|
||||
result display fields. An admin trying to add a protected field gets an error.
|
||||
Protected fields in existing or explicit code configuration are dropped, with one
|
||||
warning per table metadata lifetime; warnings never contain field values. A
|
||||
protected primary key prevents indexing the record or sending its ID for deletion.
|
||||
|
||||
Protection is separate from search eligibility: boolean, blob, hidden and other
|
||||
unsupported fields alone do not trigger result filtering or remove record labels.
|
||||
Labels are omitted when their source fields are protected; unrelated protected
|
||||
fields do not erase safe labels. Unprotected hidden keys and timestamps can still
|
||||
provide record IDs and document versions.
|
||||
|
||||
After upgrading, or after changing a field's classification or security metadata,
|
||||
run **Full Reindex** across all indexed tables to remove previously indexed values.
|
||||
Until then, tables containing protected fields are searched through allowed field
|
||||
values, and results are rebuilt without old combined labels or highlights.
|
||||
Diagnostic views also omit protected values and the raw response for these tables.
|
||||
This does not purge old OpenSearch backups or retained physical indexes; apply your
|
||||
host's retention policy to those copies.
|
||||
|
||||
This release uses the APIs in QQQ **4.1.0-RC.6**. Support for the host's
|
||||
`AuditValueProtector` classification (`ClassifiedValues`, qqq#1182) and
|
||||
`FieldAccessHelper.getLockTier` (qqq#1172) is deferred until the qbit moves to RC.7.
|
||||
@@ -48,3 +48,75 @@ Updated: 2026-10-14
|
||||
1. qqq-all and Voyage pin `1.0.0-RC.2` and report back; their feedback decides RC.3 or GA.
|
||||
2. Remaining "Before 1.0.0 GA" items in `docs/TODO.md`: stuck `REBUILDING` after a killed or overlapping full reindex (from the PR #20 review), and the `qbit-build-parent` 2.1.0 re-pin.
|
||||
3. GA: merge `release/1.0` to `main` and tag `v1.0.0`.
|
||||
|
||||
## 2026-10-14 — secured-data section 15
|
||||
|
||||
- Implemented a shared RC.6 classification/security policy in `QuickSearchFieldRules`.
|
||||
- Applied it to config discovery, config writes/choices, document construction,
|
||||
safe query fields, result presentation and diagnostic reads; protected primary
|
||||
keys cannot be used as index IDs.
|
||||
- Added live-listener/basepull/stale-result PHI/PII canaries and config/rule tests.
|
||||
- Final gate: `mvnw.cmd -o -B verify` **BUILD SUCCESS**. 479 unit tests passed
|
||||
(7 new tests); 41 Docker integration tests skipped because Docker is unavailable.
|
||||
Architecture guardrails passed. JaCoCo: 73.84% instruction / 91.61% class coverage,
|
||||
above the 70% / 90% gates. Checkstyle, PMD and SpotBugs are not configured in this
|
||||
build. No missing offline artifacts. `git diff --check` passed.
|
||||
- Reviewed the diff; changes remain uncommitted. Gate log:
|
||||
`target/secured-data-verify.log`.
|
||||
- RC.7 host audit-protector classification and lock-tier APIs remain deferred.
|
||||
|
||||
### Changed files
|
||||
|
||||
- Documentation: `README.md`, `CHANGELOG.md`, `docs/SESSION-STATE.md`, `docs/TODO.md`.
|
||||
- Configuration: `QuickSearchQBitProducer.java`, `config/QuickSearchFieldRules.java`,
|
||||
`config/QuickSearchConfigPrimer.java`, `config/QuickSearchConfigStore.java`.
|
||||
- Indexing: `listeners/QuickSearchRecordChangeListener.java`,
|
||||
`processes/IndexingUtils.java`, `processes/AbstractIndexingStep.java`,
|
||||
`processes/BasepullIndexStep.java`.
|
||||
- Search and diagnostics: `actions/QuickSearchAction.java`,
|
||||
`actions/QuickSearchRecordSearchProvider.java`,
|
||||
`opensearch/QuickSearchOpenSearchClient.java`,
|
||||
`opensearch/QuickSearchDiagnosticsOperations.java`.
|
||||
- Tests: `config/QuickSearchConfigCustomizersTest.java`,
|
||||
`listeners/QuickSearchRecordChangeListenerTest.java`,
|
||||
`processes/IndexingUtilsTest.java`, `publisher/SynchronousIndexEventPublisherTest.java`,
|
||||
new `opensearch/ClassifiedDataCanaryTest.java`.
|
||||
|
||||
Production and test paths above are relative to
|
||||
`src/{main,test}/java/com/kingsrook/qbits/quicksearch/` respectively.
|
||||
|
||||
### Continuity
|
||||
|
||||
Second-brain project/daily writes are unavailable in this session because the
|
||||
vault lies outside the writable roots. This local checkpoint holds the durable
|
||||
outcome for the reviewer. No commit, push, branch creation, dependency version
|
||||
change, or production filesystem IO was introduced.
|
||||
|
||||
|
||||
## 2026-10-14 - protection review follow-up
|
||||
|
||||
- Added isProtected for PII/PHI (including table defaults), field locks, sensitive
|
||||
values and masked types. Unsupported search types and hidden fields alone are
|
||||
not protected. Searchable/snippet/display field lists still use isIndexable.
|
||||
- Labels check their configured/table source fields; unrelated protected fields
|
||||
preserve safe labels. A copied record prevents unsafe preformatted labels from
|
||||
returning through QQQ fallback formatting without changing the input record.
|
||||
- Primary-key guards and timestamp versions now use protection rather than
|
||||
search eligibility. Added four regression tests and extended rule/canary tests.
|
||||
- Follow-up files: config/QuickSearchFieldRules.java, processes/IndexingUtils.java,
|
||||
listeners/QuickSearchRecordChangeListener.java,
|
||||
opensearch/QuickSearchOpenSearchClient.java,
|
||||
opensearch/QuickSearchDiagnosticsOperations.java; test files
|
||||
config/QuickSearchConfigCustomizersTest.java, processes/IndexingUtilsTest.java,
|
||||
opensearch/ClassifiedDataCanaryTest.java; README.md, CHANGELOG.md,
|
||||
docs/TODO.md and this file. Java paths are relative to the existing main/test
|
||||
quicksearch packages. All earlier uncommitted work is preserved.
|
||||
- Second-brain project/daily synchronization remains unavailable because the vault
|
||||
is outside writable roots; this checkpoint is the local handoff. Nothing committed
|
||||
or pushed; no branches or dependency changes.
|
||||
- Final gate: `mvnw.cmd -o -B verify` BUILD SUCCESS; 483 unit tests passed
|
||||
(four additional tests); all three PHI/PII canaries passed. All 41 Docker
|
||||
integration tests skipped because Docker is unavailable. Architecture guards
|
||||
and JaCoCo passed (73.88% instruction / 91.61% class coverage).
|
||||
Checkstyle, PMD and SpotBugs are not configured in this build. No offline
|
||||
artifacts missing. Gate log: `target/protection-review-verify.log`.
|
||||
@@ -23,26 +23,49 @@ session to bypass `adminPermissionRules`. Cache hits still check permission.
|
||||
| Method | Path | operationId |
|
||||
| --- | --- | --- |
|
||||
| GET | `/qqq/v1/qbit/quick-search/status` | `getQuickSearchStatus` |
|
||||
| GET | `/qqq/v1/qbit/quick-search/suggest` | `getQuickSearchSuggestions` |
|
||||
|
||||
Suggestions require only a signed-in session, with table READ permissions and
|
||||
configured record locks applied to results. Do not gate the typeahead UI on the
|
||||
status endpoint's admin permission. Send `q`, optionally `limitPerTable` (default 5,
|
||||
maximum 10), `maxTables` (default 8, maximum 20), and comma-separated `tables`.
|
||||
The response contains `query`, relevance-ordered `groups` and `truncated`; each
|
||||
group has `tableName`, `tableLabel`, `hasMore` and `items` of `recordId`, `label`,
|
||||
and nullable `subtitle`. See the [API details](../README.md#typeahead-suggestions)
|
||||
for normalization, bounded-batch semantics and empty responses during outages.
|
||||
|
||||
Extension: `quick-search`, version `1.0.0`; core dependency: `4.1.0-RC.6`.
|
||||
The requested `/quick-search` prefix cannot validate on the supplied RC.6 baseline:
|
||||
`MiddlewareSpecExtensionsMetaData.ESTABLISHED_PREFIXES` only admits esb, alerts,
|
||||
assistant, and sql-workbench. We use the default namespace without modifying core.
|
||||
|
||||
Wire changes against develop: one additive endpoint and its schemas; no existing
|
||||
method, path, request, or response changed. Next UI `src/` contains no calls to a
|
||||
The suggestions change adds a second endpoint and its schemas; the status
|
||||
method, path, request and response are unchanged. Next UI `src/` contains no calls to a
|
||||
Quick Search-specific endpoint today; its core `/qqq/v1/search` integration is unchanged.
|
||||
Vendor `src/main/resources/openapi/qqq-v1/quick-search.yaml` for clients.
|
||||
|
||||
Regenerate the fragment with:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd -B test -Dtest=QuickSearchApiContractTest -Dquicksearch.openapi.update=true -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT
|
||||
./mvnw.cmd -B -Dtest=QuickSearchApiContractTest -Dquicksearch.openapi.update=true test
|
||||
./mvnw.cmd -B -Dtest=QuickSearchApiContractTest test
|
||||
```
|
||||
|
||||
The local verification override is never the committed dependency version.
|
||||
The fragment covers both registered specs using the committed dependency version.
|
||||
|
||||
## Verification and limits
|
||||
## Suggestions verification (2026-10-14)
|
||||
|
||||
The two-spec contract regenerates and passes without the update flag. Final
|
||||
`CI=true ./mvnw.cmd -B verify` passes 495 unit/guardrail tests, including strict
|
||||
HTTP checks for reader access and invalid suggestion parameters. All 9 integration
|
||||
classes fail their Docker prerequisite because this sandbox cannot access Docker's
|
||||
named pipe; the 3 new suggest integration tests compile but remain unexecuted.
|
||||
The configured JaCoCo gates pass when invoked separately with
|
||||
`./mvnw.cmd -B jacoco:report jacoco:check@check` (74.38% instruction, 91.88% class).
|
||||
New API files and changed tests pass repository Checkstyle; the full source check
|
||||
still reports 446 existing violations.
|
||||
|
||||
## Original status verification and limits
|
||||
|
||||
`./mvnw.cmd -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT` passes
|
||||
468 unit tests and JaCoCo, including strict HTTP server startup, permission checks,
|
||||
|
||||
@@ -56,3 +56,12 @@
|
||||
- [x] Status assessment, deep-dive audit (25 blockers), PRD (2026-10-04).
|
||||
- [x] 4.1.0-RC.1 compatibility verified (2026-10-04).
|
||||
- [x] All 25 audit blockers implemented and tested on the release branch (2026-10-04).
|
||||
|
||||
## Secured-data section 15 (2026-10-14)
|
||||
|
||||
- [x] Review fix: separate protection from indexability; preserve safe labels, ordinary results and unprotected hidden keys/timestamps, with regression tests.
|
||||
|
||||
- [x] Exclude PII/PHI, hidden, sensitive, password and field-locked values from indexing and results on RC.6.
|
||||
- [x] Add canary coverage for real-time indexing, basepull and stale results; refuse classified config fields.
|
||||
- [ ] On the QQQ RC.7 upgrade, honour `ClassifiedValues.isClassified(table, field)` (qqq#1182) and `FieldAccessHelper.getLockTier` (qqq#1172).
|
||||
- [ ] Deployment: rebuild existing indexes and apply host retention policy to old physical indexes/backups containing previously indexed values.
|
||||
@@ -29,6 +29,7 @@ import com.kingsrook.qbits.quicksearch.actions.QuickSearchRecordSearchProvider;
|
||||
import com.kingsrook.qbits.quicksearch.annotations.QuickSearchField;
|
||||
import com.kingsrook.qbits.quicksearch.annotations.QuickSearchable;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchApi;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchFieldRules;
|
||||
import com.kingsrook.qbits.quicksearch.listeners.QuickSearchRecordChangeListener;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchAdminAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchDiagnosticsProcessMetaDataHelper;
|
||||
@@ -380,8 +381,8 @@ public class QuickSearchQBitProducer implements QBitMetaDataProducer<QuickSearch
|
||||
for(String fieldName : CollectionUtils.nonNullList(tableConfig.getSearchableFields()))
|
||||
{
|
||||
QFieldMetaData field = table.getFields() == null ? null : table.getFields().get(fieldName);
|
||||
if(field != null && !Boolean.TRUE.equals(field.getIsHidden()) && field.getType() != null
|
||||
&& (field.getType().isStringLike() || field.getType().isIntegral()) && !field.getType().needsMasked() && !field.getIsSensitive()
|
||||
if(QuickSearchFieldRules.isIndexable(table, fieldName)
|
||||
&& (field.getType().isStringLike() || field.getType().isIntegral())
|
||||
&& !searchFields.contains(fieldName))
|
||||
{
|
||||
searchFields.add(fieldName);
|
||||
@@ -466,7 +467,7 @@ public class QuickSearchQBitProducer implements QBitMetaDataProducer<QuickSearch
|
||||
{
|
||||
primaryKeyField = table.getPrimaryKeyField();
|
||||
basepullTimestampField = resolveTimestampField(table, searchableFields, basepullTimestampField);
|
||||
removeHiddenFields(table, searchableFields, fieldWeights, fieldIncludeLabels);
|
||||
removeExcludedFields(table, searchableFields, fieldWeights, fieldIncludeLabels);
|
||||
}
|
||||
|
||||
tables.add(new QuickSearchableTableConfig()
|
||||
@@ -523,7 +524,7 @@ public class QuickSearchQBitProducer implements QBitMetaDataProducer<QuickSearch
|
||||
warnIncrementalBasepullDisabled(table, basepullTimestampField);
|
||||
basepullTimestampField = null;
|
||||
}
|
||||
removeHiddenFields(table, searchableFields, fieldWeights, fieldIncludeLabels);
|
||||
removeExcludedFields(table, searchableFields, fieldWeights, fieldIncludeLabels);
|
||||
}
|
||||
|
||||
Integer basepullInterval = stc.getBasepullIntervalMinutes();
|
||||
@@ -603,23 +604,22 @@ public class QuickSearchQBitProducer implements QBitMetaDataProducer<QuickSearch
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hidden, isSensitive and password fields are never indexed: their values
|
||||
** Classified, locked, hidden, sensitive and password fields are excluded:
|
||||
** would leak through searchable text and highlights.
|
||||
*******************************************************************************/
|
||||
private static void removeHiddenFields(QTableMetaData table, List<String> searchableFields, Map<String, Integer> fieldWeights, Map<String, Boolean> fieldIncludeLabels)
|
||||
private static void removeExcludedFields(QTableMetaData table, List<String> searchableFields, Map<String, Integer> fieldWeights, Map<String, Boolean> fieldIncludeLabels)
|
||||
{
|
||||
List<String> hidden = new ArrayList<>();
|
||||
for(String fieldName : searchableFields)
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(fieldName);
|
||||
if(field != null && (Boolean.TRUE.equals(field.getIsHidden()) || field.getIsSensitive() || (field.getType() != null && field.getType().needsMasked())))
|
||||
if(!QuickSearchFieldRules.isIndexable(table, fieldName))
|
||||
{
|
||||
hidden.add(fieldName);
|
||||
}
|
||||
}
|
||||
if(!hidden.isEmpty())
|
||||
{
|
||||
LOG.warn("Hidden, sensitive and password fields are excluded from Quick Search indexing", logPair("tableName", table.getName()), logPair("fields", hidden));
|
||||
QuickSearchFieldRules.warnExcludedFields(table);
|
||||
searchableFields.removeAll(hidden);
|
||||
hidden.forEach(fieldWeights::remove);
|
||||
hidden.forEach(fieldIncludeLabels::remove);
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitContext;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchFieldRules;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsSupport;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchDiagnosticEvent;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument;
|
||||
@@ -165,7 +166,7 @@ public class QuickSearchAction
|
||||
|
||||
if(input.getLimitPerTable() != null && input.getLimitPerTable() > 0)
|
||||
{
|
||||
return (searchPerTable(client, normalizedTerm, allowedTables, offset, Math.min(input.getLimitPerTable(), maxLimit), tableConfigs, applyLocks));
|
||||
return (searchPerTable(client, normalizedTerm, allowedTables, offset, Math.min(input.getLimitPerTable(), maxLimit), tableConfigs, applyLocks, Boolean.TRUE.equals(input.getSinglePass())));
|
||||
}
|
||||
|
||||
Page page = searchPage(client, normalizedTerm, allowedTables, offset, limit, tableConfigs, applyLocks, new ScanBudget(QuickSearchOpenSearchClient.MAX_RESULT_WINDOW, MAX_LOCKED_ROUND_TRIPS));
|
||||
@@ -177,8 +178,10 @@ public class QuickSearchAction
|
||||
/*******************************************************************************
|
||||
** Search each allowed table separately, skipping offset results and
|
||||
** returning up to limitPerTable hits in each table.
|
||||
** singlePass bounds locked searches to one batch of twice the requested
|
||||
** page size; it never scans again to fill gaps left by inaccessible rows.
|
||||
*******************************************************************************/
|
||||
private QuickSearchOutput searchPerTable(QuickSearchOpenSearchClient client, String term, List<String> allowedTables, int offset, int limitPerTable, List<QuickSearchableTableConfig> tableConfigs, boolean applyLocks) throws QException
|
||||
private QuickSearchOutput searchPerTable(QuickSearchOpenSearchClient client, String term, List<String> allowedTables, int offset, int limitPerTable, List<QuickSearchableTableConfig> tableConfigs, boolean applyLocks, boolean singlePass) throws QException
|
||||
{
|
||||
List<QuickSearchResult> results = new ArrayList<>();
|
||||
long totalHits = 0;
|
||||
@@ -188,6 +191,11 @@ public class QuickSearchAction
|
||||
int window = QuickSearchOpenSearchClient.MAX_RESULT_WINDOW;
|
||||
int tableHits = Math.max(window / allowedTables.size(), Math.min(window, 2 * limitPerTable));
|
||||
int tableTrips = Math.max(2, MAX_LOCKED_ROUND_TRIPS / allowedTables.size());
|
||||
if(singlePass)
|
||||
{
|
||||
tableHits = Math.min(window, 2 * limitPerTable);
|
||||
tableTrips = 1;
|
||||
}
|
||||
|
||||
for(String tableName : allowedTables)
|
||||
{
|
||||
@@ -221,7 +229,7 @@ public class QuickSearchAction
|
||||
if(!applyLocks)
|
||||
{
|
||||
SearchResponse<OpenSearchDocument> response = runSearch(client, term, tables, pageSize, offset, tableConfigs, null);
|
||||
List<QuickSearchResult> results = toResults(response);
|
||||
List<QuickSearchResult> results = toResults(response, tableConfigs);
|
||||
|
||||
long totalHits = response.hits().total() != null ? response.hits().total().value() : results.size();
|
||||
boolean lowerBound = response.hits().total() != null && response.hits().total().relation() == TotalHitsRelation.Gte;
|
||||
@@ -255,7 +263,7 @@ public class QuickSearchAction
|
||||
rawFloor = response.hits().total() != null && response.hits().total().relation() == TotalHitsRelation.Gte;
|
||||
|
||||
List<QuickSearchResult> candidates = new ArrayList<>();
|
||||
for(QuickSearchResult result : toResults(response))
|
||||
for(QuickSearchResult result : toResults(response, tableConfigs))
|
||||
{
|
||||
if(seen.add(result.getTableName() + ":" + result.getRecordId()))
|
||||
{
|
||||
@@ -315,7 +323,7 @@ public class QuickSearchAction
|
||||
List<String> allowed = new ArrayList<>();
|
||||
for(String tableName : new LinkedHashSet<>(requested))
|
||||
{
|
||||
if(!configured.contains(tableName))
|
||||
if(!configured.contains(tableName) || QuickSearchFieldRules.hasProtectedPrimaryKey(tableName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -407,7 +415,7 @@ public class QuickSearchAction
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<QuickSearchResult> toResults(SearchResponse<OpenSearchDocument> response)
|
||||
private static List<QuickSearchResult> toResults(SearchResponse<OpenSearchDocument> response, List<QuickSearchableTableConfig> tableConfigs)
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
List<QuickSearchResult> results = new ArrayList<>();
|
||||
@@ -420,9 +428,16 @@ public class QuickSearchAction
|
||||
continue;
|
||||
}
|
||||
|
||||
String highlightSnippet = null;
|
||||
String sourceTable = source.getSourceTable();
|
||||
boolean filterFields = QuickSearchFieldRules.requiresFieldFiltering(sourceTable);
|
||||
source = IndexingUtils.filterResult(source, tableConfigs.stream().filter(c -> sourceTable.equals(c.getTableName())).findFirst().orElse(null));
|
||||
if(source == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String highlightSnippet = filterFields ? source.getSearchableText() : null;
|
||||
Map<String, List<String>> highlight = hit.highlight();
|
||||
if(highlight != null)
|
||||
if(highlight != null && !filterFields)
|
||||
{
|
||||
List<String> fragments = highlight.get("searchableText");
|
||||
if(fragments != null && !fragments.isEmpty())
|
||||
|
||||
@@ -35,6 +35,28 @@ public class QuickSearchInput extends AbstractActionInput
|
||||
private Integer offset;
|
||||
private List<String> tableNames;
|
||||
private Integer limitPerTable;
|
||||
private Boolean singlePass = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether per-table searches stop after one bounded candidate batch.
|
||||
*******************************************************************************/
|
||||
public Boolean getSinglePass()
|
||||
{
|
||||
return (singlePass);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt in to bounded typeahead latency, accepting partially filled pages.
|
||||
*******************************************************************************/
|
||||
public QuickSearchInput withSinglePass(Boolean singlePass)
|
||||
{
|
||||
this.singlePass = singlePass;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+4
-2
@@ -32,6 +32,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchFieldRules;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsSupport;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchDiagnosticEvent;
|
||||
import com.kingsrook.qbits.quicksearch.processes.IndexingUtils;
|
||||
@@ -85,7 +86,7 @@ public class QuickSearchRecordSearchProvider implements RecordSearchProviderInte
|
||||
return (false);
|
||||
}
|
||||
|
||||
if(runtime.getTableConfig(table.getName()) == null)
|
||||
if(runtime.getTableConfig(table.getName()) == null || QuickSearchFieldRules.hasProtectedPrimaryKey(table.getName()))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
@@ -120,7 +121,8 @@ public class QuickSearchRecordSearchProvider implements RecordSearchProviderInte
|
||||
List<String> tableNames = new ArrayList<>();
|
||||
for(String tableName : new LinkedHashSet<>(CollectionUtils.nonNullList(input.getTableNames())))
|
||||
{
|
||||
if(runtime.getTableConfig(tableName) != null)
|
||||
if(runtime.getTableConfig(tableName) != null
|
||||
&& !QuickSearchFieldRules.hasProtectedPrimaryKey(tableName))
|
||||
{
|
||||
tableNames.add(tableName);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,6 @@ public final class QuickSearchApi
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("quick-search").withSemanticVersion("1.0.0")
|
||||
.withDescription("Quick Search administrative status").withSpec(QuickSearchStatusSpec.class));
|
||||
.withDescription("Quick Search administration and suggestions").withSpec(QuickSearchStatusSpec.class).withSpec(QuickSearchSuggestSpec.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal immutable suggestion response. Search internals never cross this API.
|
||||
*******************************************************************************/
|
||||
public record QuickSearchSuggest(String query, List<Group> groups, Boolean truncated)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Freeze the group order for callers.
|
||||
*******************************************************************************/
|
||||
public QuickSearchSuggest
|
||||
{
|
||||
groups = List.copyOf(groups);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One readable table, ordered by its highest scoring accessible hit.
|
||||
*******************************************************************************/
|
||||
public record Group(String tableName, String tableLabel, Boolean hasMore, List<Item> items)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Freeze the per-table relevance order.
|
||||
*******************************************************************************/
|
||||
public Group
|
||||
{
|
||||
items = List.copyOf(items);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only already-filtered labels and subtitles are exposed.
|
||||
*******************************************************************************/
|
||||
public record Item(String recordId, String label, String subtitle)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.api;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
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.processes.IndexingUtils;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Session-only typeahead over the existing permission-aware search action.
|
||||
*******************************************************************************/
|
||||
public final class QuickSearchSuggestService
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(QuickSearchSuggestService.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QuickSearchSuggestService()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One candidate batch per table, with one extra visible hit for hasMore.
|
||||
** A short term or unavailable search returns an empty response. hasMore only
|
||||
** confirms an extra accessible hit in this bounded batch, never hidden rows.
|
||||
*******************************************************************************/
|
||||
public static QuickSearchSuggest suggest(String query, Integer limitPerTable, Integer maxTables, String tables) throws QException
|
||||
{
|
||||
if(QContext.getQSession() == null)
|
||||
{
|
||||
throw (new QPermissionDeniedException("A session is required for Quick Search suggestions."));
|
||||
}
|
||||
if(query == null)
|
||||
{
|
||||
throw (new QBadRequestException("q is required."));
|
||||
}
|
||||
String normalized = IndexingUtils.normalizeSearchText(query);
|
||||
if(normalized.length() > QuickSearchAction.MAX_TERM_LENGTH)
|
||||
{
|
||||
throw (new QBadRequestException("Search term is too long (maximum 100 characters)."));
|
||||
}
|
||||
Integer itemLimit = bounded(limitPerTable, 5, 10, "limitPerTable");
|
||||
Integer groupLimit = bounded(maxTables, 8, 20, "maxTables");
|
||||
QuickSearchSuggest empty = new QuickSearchSuggest(normalized, List.of(), false);
|
||||
QuickSearchRuntime runtime = QuickSearchRuntime.get();
|
||||
if(normalized.length() < QuickSearchAction.MIN_TERM_LENGTH || runtime == null)
|
||||
{
|
||||
return (empty);
|
||||
}
|
||||
|
||||
List<String> tableNames = tables == null ? null : Arrays.stream(tables.split(",")).map(String::trim).filter(name -> !name.isEmpty()).distinct().toList();
|
||||
if(tableNames != null && tableNames.isEmpty())
|
||||
{
|
||||
return (empty);
|
||||
}
|
||||
|
||||
QuickSearchOutput output;
|
||||
try
|
||||
{
|
||||
if(runtime.getClient() == null)
|
||||
{
|
||||
return (empty);
|
||||
}
|
||||
output = new QuickSearchAction().execute(new QuickSearchInput().withSearchTerm(normalized)
|
||||
.withTableNames(tableNames).withLimitPerTable(itemLimit + 1).withSinglePass(true));
|
||||
}
|
||||
catch(QPermissionDeniedException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.debug("Quick Search suggestions unavailable", e);
|
||||
return (empty);
|
||||
}
|
||||
|
||||
Map<String, List<QuickSearchResult>> byTable = new LinkedHashMap<>();
|
||||
for(QuickSearchResult result : output.getResults())
|
||||
{
|
||||
byTable.computeIfAbsent(result.getTableName(), key -> new ArrayList<>()).add(result);
|
||||
}
|
||||
List<List<QuickSearchResult>> ranked = new ArrayList<>(byTable.values());
|
||||
ranked.sort(Comparator.<List<QuickSearchResult>, Float>comparing(
|
||||
hits -> hits.stream().map(QuickSearchResult::getScore).filter(score -> score != null).max(Float::compare).orElse(Float.NEGATIVE_INFINITY))
|
||||
.reversed().thenComparing(hits -> hits.get(0).getTableName()));
|
||||
|
||||
List<QuickSearchSuggest.Group> groups = new ArrayList<>();
|
||||
for(List<QuickSearchResult> hits : ranked.subList(0, Math.min(groupLimit, ranked.size())))
|
||||
{
|
||||
QuickSearchResult first = hits.get(0);
|
||||
List<QuickSearchSuggest.Item> items = hits.stream().limit(itemLimit).map(hit ->
|
||||
{
|
||||
String label = hit.getRecordLabel();
|
||||
if(label == null || label.isBlank())
|
||||
{
|
||||
label = hit.getTableLabel() + " #" + hit.getRecordId();
|
||||
}
|
||||
return (new QuickSearchSuggest.Item(hit.getRecordId(), label, hit.getSubtitle()));
|
||||
}).toList();
|
||||
groups.add(new QuickSearchSuggest.Group(first.getTableName(), first.getTableLabel(), hits.size() > itemLimit, items));
|
||||
}
|
||||
return (new QuickSearchSuggest(normalized, groups, ranked.size() > groupLimit));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject invalid ranges instead of silently changing a caller's intent.
|
||||
*******************************************************************************/
|
||||
private static Integer bounded(Integer value, Integer defaultValue, Integer maximum, String name) throws QBadRequestException
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (defaultValue);
|
||||
}
|
||||
if(value < 1 || value > maximum)
|
||||
{
|
||||
throw (new QBadRequestException(name + " must be between 1 and " + maximum + "."));
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Method;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission-filtered suggestions through the spec-managed UI API.
|
||||
*******************************************************************************/
|
||||
public class QuickSearchSuggestSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the session-only operation and its bounded search semantics.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/qbit/quick-search/suggest")
|
||||
.withShortSummary("Get Quick Search suggestions")
|
||||
.withLongDescription("Requires a signed-in session, not Quick Search admin permission. Results inherit table READ permissions, configured record security locks and protected-field exclusions. One bounded candidate batch per table; no extra scans. Unavailable search and normalized terms shorter than two characters return empty groups."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable generated-client operation name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeMethod(Method method)
|
||||
{
|
||||
method.setOperationId("getQuickSearchSuggestions");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounds apply after whitespace normalization; short terms are forgiving.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (List.of(
|
||||
new Parameter().withName("q").withIn(In.QUERY).withRequired(true)
|
||||
.withDescription("Search text: trim and collapse whitespace before applying the 2..100 character range. Shorter terms return empty groups; longer terms return 400.")
|
||||
.withSchema(field(Type.STRING, "Normalized search term.", false)),
|
||||
new Parameter().withName("limitPerTable").withIn(In.QUERY).withRequired(false)
|
||||
.withDescription("Items per table: integer 1..10, default 5.").withSchema(field(Type.INTEGER, "Maximum items per table; default 5, maximum 10.", false)),
|
||||
new Parameter().withName("maxTables").withIn(In.QUERY).withRequired(false)
|
||||
.withDescription("Groups to return: integer 1..20, default 8.").withSchema(field(Type.INTEGER, "Maximum groups; default 8, maximum 20.", false)),
|
||||
new Parameter().withName("tables").withIn(In.QUERY).withRequired(false)
|
||||
.withDescription("Optional comma-separated table metadata names. Unknown, disabled and unreadable tables are omitted.")
|
||||
.withSchema(field(Type.STRING, "Table-name filter; omitted means all eligible tables.", false))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response component.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("QuickSearchSuggestV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The HTTP and in-process entry points share validation and authorization.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (QuickSearchSuggestService.suggest(input.getQueryParam("q"), input.getQueryParamInteger("limitPerTable"),
|
||||
input.getQueryParamInteger("maxTables"), input.getQueryParam("tables")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse the extension's standard framework error shape.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Missing q, normalized term longer than 100 characters, or invalid limits", "QuickSearchErrorV1"),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "QuickSearchErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Session permission denied", "QuickSearchErrorV1")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit wire schemas exclude scores, highlights and display values.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
Schema response = object(Map.of(
|
||||
"query", field(Type.STRING, "Search term after trimming and collapsing whitespace.", false),
|
||||
"groups", array("QuickSearchSuggestGroupV1", "Readable tables ordered by best accessible score; at most maxTables."),
|
||||
"truncated", field(Type.BOOLEAN, "More tables had accessible hits than maxTables.", false)));
|
||||
Schema group = object(Map.of(
|
||||
"tableName", field(Type.STRING, "Source table metadata name.", false),
|
||||
"tableLabel", field(Type.STRING, "Source table label, falling back to its metadata name.", false),
|
||||
"hasMore", field(Type.BOOLEAN, "An extra accessible item was found in the bounded batch. False does not prove the index has no further matches.", false),
|
||||
"items", array("QuickSearchSuggestItemV1", "At most limitPerTable accessible items in per-table relevance order.")));
|
||||
Schema item = object(Map.of(
|
||||
"recordId", field(Type.STRING, "Source record primary key.", false),
|
||||
"label", field(Type.STRING, "Filtered record label, falling back to tableLabel #recordId when blank.", false),
|
||||
"subtitle", field(Type.STRING, "Already-filtered result subtitle; null when absent.", true)));
|
||||
return (Map.of("QuickSearchSuggestV1", response, "QuickSearchSuggestGroupV1", group, "QuickSearchSuggestItemV1", item));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All response objects have explicit properties.
|
||||
*******************************************************************************/
|
||||
private static Schema object(Map<String, Schema> properties)
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withNullable(false).withProperties(new TreeMap<>(properties)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scalar fields declare nullability and meaning.
|
||||
*******************************************************************************/
|
||||
private static Schema field(Type type, String description, Boolean nullable)
|
||||
{
|
||||
return (new Schema().withType(type).withDescription(description).withNullable(nullable));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Arrays always exist, including when empty.
|
||||
*******************************************************************************/
|
||||
private static Schema array(String itemSchema, String description)
|
||||
{
|
||||
return (field(Type.ARRAY, description, false).withItems(new Schema().withRef("#/components/schemas/" + itemSchema)));
|
||||
}
|
||||
}
|
||||
@@ -238,8 +238,7 @@ public class QuickSearchConfigPrimer
|
||||
}
|
||||
if(!QuickSearchFieldRules.isIndexable(table, fieldName))
|
||||
{
|
||||
LOG.warn("A field declared searchable in code cannot be indexed; it is not primed: " + QuickSearchFieldRules.whyNotIndexable(table, fieldName),
|
||||
logPair("tableName", tableName), logPair("fieldName", fieldName));
|
||||
QuickSearchFieldRules.warnExcludedFields(table);
|
||||
continue;
|
||||
}
|
||||
codeFields.add(codeFieldRow(code, fieldName, i));
|
||||
|
||||
@@ -377,7 +377,7 @@ public class QuickSearchConfigStore
|
||||
String problem = QuickSearchFieldRules.whyNotIndexable(table, fieldName);
|
||||
if(problem != null)
|
||||
{
|
||||
LOG.warn("Quick Search field row skipped: " + problem, logPair("tableName", tableName), logPair("fieldName", fieldName));
|
||||
QuickSearchFieldRules.warnExcludedFields(table);
|
||||
continue;
|
||||
}
|
||||
if(fields.contains(fieldName))
|
||||
|
||||
@@ -23,9 +23,14 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -48,11 +53,14 @@ import com.kingsrook.qbits.quicksearch.model.QuickSearchFieldRole;
|
||||
** on backend variants, not produced by a QBit (qbit-internal), not a QQQ
|
||||
** system table (audits, saved views and reports, scheduled jobs, scripts),
|
||||
** not named like a secret store, and has at least one indexable field. A
|
||||
** field is indexable when it is not hidden, not isSensitive, not a password
|
||||
** and not a blob or boolean.
|
||||
** field is indexable when it is not PII/PHI-classified, locked, hidden,
|
||||
** sensitive, masked, a blob or a boolean.
|
||||
*******************************************************************************/
|
||||
public final class QuickSearchFieldRules
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(QuickSearchFieldRules.class);
|
||||
private static final WeakHashMap<QTableMetaData, Boolean> WARNED_TABLES = new WeakHashMap<>();
|
||||
|
||||
public static final int MAX_DEFAULT_FIELDS = 20;
|
||||
public static final int TITLE_WEIGHT = 3;
|
||||
|
||||
@@ -85,6 +93,9 @@ public final class QuickSearchFieldRules
|
||||
|
||||
/*******************************************************************************
|
||||
** Why a field cannot be indexed, or null when it can.
|
||||
** When the qbit moves to QQQ 4.1.0-RC.7, also honour core
|
||||
** ClassifiedValues.isClassified(table, field) (the host's AuditValueProtector
|
||||
** classification, qqq#1182) and FieldAccessHelper.getLockTier (qqq#1172).
|
||||
*******************************************************************************/
|
||||
public static String whyNotIndexable(QTableMetaData table, String fieldName)
|
||||
{
|
||||
@@ -101,6 +112,15 @@ public final class QuickSearchFieldRules
|
||||
{
|
||||
return ("field [" + fieldName + "] is hidden");
|
||||
}
|
||||
DataClassification classification = AiDataClassifications.ofField(table, fieldName);
|
||||
if(classification == DataClassification.PII || classification == DataClassification.PHI)
|
||||
{
|
||||
return ("field [" + fieldName + "] is classified " + classification + " and cannot be indexed");
|
||||
}
|
||||
if(field.getFieldSecurityLock() != null)
|
||||
{
|
||||
return ("field [" + fieldName + "] has a field security lock and cannot be indexed");
|
||||
}
|
||||
if(field.getIsSensitive() || (field.getType() != null && field.getType().needsMasked()))
|
||||
{
|
||||
return ("field [" + fieldName + "] is sensitive (its values must not reach the search index)");
|
||||
@@ -124,6 +144,96 @@ public final class QuickSearchFieldRules
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a field contains protected values, independently of searchability.
|
||||
** Classification falls back to the table. When the qbit moves to QQQ
|
||||
** 4.1.0-RC.7, also honour ClassifiedValues.isClassified(table, field)
|
||||
** (qqq#1182) and FieldAccessHelper.getLockTier (qqq#1172).
|
||||
*******************************************************************************/
|
||||
public static boolean isProtected(QTableMetaData table, String fieldName)
|
||||
{
|
||||
QFieldMetaData field = table == null || fieldName == null ? null : table.getFields().get(fieldName);
|
||||
if(field == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
DataClassification classification = AiDataClassifications.ofField(table, fieldName);
|
||||
return (classification == DataClassification.PII || classification == DataClassification.PHI
|
||||
|| field.getFieldSecurityLock() != null || field.getIsSensitive()
|
||||
|| (field.getType() != null && field.getType().needsMasked()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Warn once per table metadata lifetime; never include record values.
|
||||
*******************************************************************************/
|
||||
public static synchronized void warnExcludedFields(QTableMetaData table)
|
||||
{
|
||||
if(table != null && WARNED_TABLES.put(table, Boolean.TRUE) == null)
|
||||
{
|
||||
LOG.warn("Quick Search dropped fields that cannot be indexed (classified PII/PHI, secured, hidden, sensitive or unsupported fields) from table [" + table.getName() + "]");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Filter explicit field lists as well as cached configurations at use time.
|
||||
*******************************************************************************/
|
||||
public static List<String> indexableFields(QTableMetaData table, List<String> fields)
|
||||
{
|
||||
List<String> result = new ArrayList<>();
|
||||
for(String field : CollectionUtils.nonNullList(fields))
|
||||
{
|
||||
if(isIndexable(table, field))
|
||||
{
|
||||
result.add(field);
|
||||
}
|
||||
else
|
||||
{
|
||||
warnExcludedFields(table);
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve a table's metadata for backend query and result filtering.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData lookupTable(String tableName)
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
return (instance == null || tableName == null ? null : instance.getTable(tableName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Combined text and labels from an older index cannot be attributed to
|
||||
** individual fields. Use only allowed field values for these tables.
|
||||
*******************************************************************************/
|
||||
public static boolean requiresFieldFiltering(String tableName)
|
||||
{
|
||||
QTableMetaData table = lookupTable(tableName);
|
||||
return (table != null && table.getFields().keySet().stream().anyMatch(f -> isProtected(table, f)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A protected primary key cannot be sent as an index id, even for deletes.
|
||||
*******************************************************************************/
|
||||
public static boolean hasProtectedPrimaryKey(String tableName)
|
||||
{
|
||||
QTableMetaData table = lookupTable(tableName);
|
||||
return (table != null && isProtected(table, table.getPrimaryKeyField()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The indexable fields of a table, in the table's order.
|
||||
*******************************************************************************/
|
||||
|
||||
+16
-1
@@ -120,6 +120,11 @@ public class QuickSearchRecordChangeListener implements RecordChangeListenerInte
|
||||
}
|
||||
|
||||
String primaryKeyField = tableConfig.getPrimaryKeyField() != null ? tableConfig.getPrimaryKeyField() : "id";
|
||||
if(QuickSearchFieldRules.lookupTable(tableName) == null || QuickSearchFieldRules.isProtected(QuickSearchFieldRules.lookupTable(tableName), primaryKeyField))
|
||||
{
|
||||
QuickSearchFieldRules.warnExcludedFields(QuickSearchFieldRules.lookupTable(tableName));
|
||||
return;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// paused: index nothing, but keep the written keys (DEFERRED), which //
|
||||
@@ -514,7 +519,17 @@ public class QuickSearchRecordChangeListener implements RecordChangeListenerInte
|
||||
{
|
||||
continue;
|
||||
}
|
||||
events.add(new IndexEvent().withAction(IndexEventAction.INDEX).withTableName(tableName).withRecordId(recordId).withRecord(record));
|
||||
QRecord safeRecord = new QRecord();
|
||||
for(var field : QuickSearchFieldRules.indexableFields(QuickSearchFieldRules.lookupTable(tableName)))
|
||||
{
|
||||
safeRecord.setValue(field.getName(), record.getValue(field.getName()));
|
||||
safeRecord.setDisplayValue(field.getName(), record.getDisplayValue(field.getName()));
|
||||
}
|
||||
if(!QuickSearchFieldRules.requiresFieldFiltering(tableName))
|
||||
{
|
||||
safeRecord.setRecordLabel(record.getRecordLabel());
|
||||
}
|
||||
events.add(new IndexEvent().withAction(IndexEventAction.INDEX).withTableName(tableName).withRecordId(recordId).withRecord(safeRecord));
|
||||
}
|
||||
return (events);
|
||||
}
|
||||
|
||||
+54
-8
@@ -33,6 +33,8 @@ import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchFieldRules;
|
||||
import com.kingsrook.qbits.quicksearch.processes.IndexingUtils;
|
||||
import jakarta.json.stream.JsonGenerator;
|
||||
import org.opensearch.client.json.JsonpMapper;
|
||||
import org.opensearch.client.json.JsonpSerializable;
|
||||
@@ -42,6 +44,7 @@ import org.opensearch.client.opensearch._types.query_dsl.Query;
|
||||
import org.opensearch.client.opensearch.core.ExplainResponse;
|
||||
import org.opensearch.client.opensearch.core.SearchRequest;
|
||||
import org.opensearch.client.opensearch.core.SearchResponse;
|
||||
import org.opensearch.client.opensearch.core.search.Highlight;
|
||||
import org.opensearch.client.opensearch.core.search.Hit;
|
||||
import org.opensearch.client.opensearch.core.search.HighlightField;
|
||||
import org.opensearch.client.opensearch.core.search.MatchedQueries;
|
||||
@@ -169,13 +172,14 @@ public class QuickSearchDiagnosticsOperations
|
||||
{
|
||||
int boundedSize = Math.max(1, Math.min(size, MAX_PROBE_SIZE));
|
||||
Query query = quickSearchClient.buildSearchQuery(term, List.of(tableName), tableConfigs, true);
|
||||
Highlight highlight = QuickSearchFieldRules.requiresFieldFiltering(tableName) ? null : Highlight.of(h -> h
|
||||
.requireFieldMatch(true)
|
||||
.fields("searchableText", HighlightField.of(hf -> hf))
|
||||
.fields("fieldValues.*", HighlightField.of(hf -> hf)));
|
||||
SearchRequest request = SearchRequest.of(r -> r
|
||||
.index(indexName)
|
||||
.query(query)
|
||||
.highlight(h -> h
|
||||
.requireFieldMatch(true)
|
||||
.fields("searchableText", HighlightField.of(hf -> hf))
|
||||
.fields("fieldValues.*", HighlightField.of(hf -> hf)))
|
||||
.highlight(highlight)
|
||||
.trackTotalHits(t -> t.count(QuickSearchOpenSearchClient.MAX_RESULT_WINDOW))
|
||||
.size(boundedSize));
|
||||
|
||||
@@ -189,17 +193,26 @@ public class QuickSearchDiagnosticsOperations
|
||||
for(Hit<OpenSearchDocument> hit : response.hits().hits())
|
||||
{
|
||||
OpenSearchDocument source = hit.source();
|
||||
boolean filterFields = QuickSearchFieldRules.requiresFieldFiltering(tableName);
|
||||
if(source != null)
|
||||
{
|
||||
source = IndexingUtils.filterResult(source, tableConfigs.stream().filter(c -> tableName.equals(c.getTableName())).findFirst().orElse(null));
|
||||
}
|
||||
if(filterFields && source == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
hits.add(new ProbeHit(
|
||||
source == null ? idFromDocumentId(hit.id()) : source.getRecordId(),
|
||||
source == null ? null : source.getRecordLabel(),
|
||||
hit.score(),
|
||||
matchedClauses(hit.matchedQueries()),
|
||||
hit.highlight() == null ? Map.of() : new LinkedHashMap<>(hit.highlight())));
|
||||
filterFields || hit.highlight() == null ? Map.of() : new LinkedHashMap<>(hit.highlight())));
|
||||
}
|
||||
|
||||
Long total = response.hits().total() == null ? (long) hits.size() : response.hits().total().value();
|
||||
Boolean lowerBound = response.hits().total() != null && response.hits().total().relation() == TotalHitsRelation.Gte;
|
||||
return (new SearchProbe(tableName, toJson(request), toJson(response), response.took(), wall, total, lowerBound, hits));
|
||||
return (new SearchProbe(tableName, toJson(request), QuickSearchFieldRules.requiresFieldFiltering(tableName) ? "{}" : toJson(response), response.took(), wall, total, lowerBound, hits));
|
||||
}
|
||||
catch(OpenSearchException | IOException e)
|
||||
{
|
||||
@@ -308,7 +321,7 @@ public class QuickSearchDiagnosticsOperations
|
||||
requireOk(response, "Document read failed");
|
||||
JsonNode body = response.body();
|
||||
return (new DocumentView(body.path("found").asBoolean(false), body.has("_version") ? body.get("_version").asLong() : null,
|
||||
body.path("_index").asText(null), body.get("_source")));
|
||||
body.path("_index").asText(null), filterDocumentSource(tableName, body.get("_source"))));
|
||||
}
|
||||
|
||||
|
||||
@@ -338,7 +351,7 @@ public class QuickSearchDiagnosticsOperations
|
||||
{
|
||||
if(doc.path("found").asBoolean(false) && doc.has("_source"))
|
||||
{
|
||||
sources.put(idFromDocumentId(doc.path("_id").asText()), doc.get("_source"));
|
||||
sources.put(idFromDocumentId(doc.path("_id").asText()), filterDocumentSource(tableName, doc.get("_source")));
|
||||
}
|
||||
}
|
||||
return (sources);
|
||||
@@ -346,6 +359,39 @@ public class QuickSearchDiagnosticsOperations
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Old documents may retain protected values. Diagnostic reads expose only
|
||||
** attributable, indexable fields, never the old combined presentation.
|
||||
*******************************************************************************/
|
||||
private static JsonNode filterDocumentSource(String tableName, JsonNode source)
|
||||
{
|
||||
if(source == null || !QuickSearchFieldRules.requiresFieldFiltering(tableName))
|
||||
{
|
||||
return (source);
|
||||
}
|
||||
ObjectNode filtered = JsonNodeFactory.instance.objectNode();
|
||||
filtered.put("sourceTable", tableName);
|
||||
var table = QuickSearchFieldRules.lookupTable(tableName);
|
||||
if(!QuickSearchFieldRules.isProtected(table, table.getPrimaryKeyField()))
|
||||
{
|
||||
filtered.set("recordId", source.get("recordId"));
|
||||
}
|
||||
for(String container : List.of("fieldValues", "fieldValuesWord", "fieldValuesEnglish", "fieldValuesExact"))
|
||||
{
|
||||
ObjectNode values = filtered.putObject(container);
|
||||
source.path(container).fields().forEachRemaining(entry ->
|
||||
{
|
||||
if(QuickSearchFieldRules.isIndexable(table, entry.getKey()))
|
||||
{
|
||||
values.set(entry.getKey(), entry.getValue());
|
||||
}
|
||||
});
|
||||
}
|
||||
return (filtered);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run _analyze on the index with a named analyzer (an index analyzer or a
|
||||
** built-in one). Text is truncated to MAX_ANALYZE_TEXT_LENGTH.
|
||||
|
||||
+57
-6
@@ -36,6 +36,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchFieldRules;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchFieldAnalyzer;
|
||||
import com.kingsrook.qbits.quicksearch.processes.IndexingUtils;
|
||||
import org.opensearch.client.json.JsonData;
|
||||
@@ -53,6 +54,7 @@ import org.opensearch.client.opensearch._types.query_dsl.MultiMatchQuery;
|
||||
import org.opensearch.client.opensearch._types.query_dsl.Operator;
|
||||
import org.opensearch.client.opensearch._types.query_dsl.Query;
|
||||
import org.opensearch.client.opensearch._types.query_dsl.TermQuery;
|
||||
import org.opensearch.client.opensearch._types.query_dsl.TextQueryType;
|
||||
import org.opensearch.client.opensearch.core.BulkRequest;
|
||||
import org.opensearch.client.opensearch.core.BulkResponse;
|
||||
import org.opensearch.client.opensearch.core.DeleteByQueryRequest;
|
||||
@@ -796,7 +798,8 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
try
|
||||
{
|
||||
Query finalQuery = buildSearchQuery(searchTerm, allowedTables, tableConfigs);
|
||||
Highlight highlight = Highlight.of(h -> h.fields("searchableText", HighlightField.of(hf -> hf)));
|
||||
Highlight highlight = allowedTables.stream().anyMatch(QuickSearchFieldRules::requiresFieldFiltering) ? null
|
||||
: Highlight.of(h -> h.fields("searchableText", HighlightField.of(hf -> hf)));
|
||||
|
||||
SearchRequest request = SearchRequest.of(r -> r
|
||||
.index(indexName)
|
||||
@@ -941,9 +944,22 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
// fields kept out of the snippet (KEYWORD, EXACT, or includeInSnippet //
|
||||
// off) match on their own; with none, the query is exactly as before //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
List<String> aggregateTables = allowedTables.stream().filter(t -> !QuickSearchFieldRules.requiresFieldFiltering(t)).toList();
|
||||
if(aggregateTables.isEmpty())
|
||||
{
|
||||
snippetMatch = Query.of(q -> q.matchNone(m -> m));
|
||||
}
|
||||
else if(aggregateTables.size() != allowedTables.size())
|
||||
{
|
||||
Query aggregateMatch = snippetMatch;
|
||||
snippetMatch = Query.of(q -> q.bool(b -> b.must(aggregateMatch).filter(f -> f.terms(t -> t.field("sourceTable")
|
||||
.terms(v -> v.value(aggregateTables.stream().map(FieldValue::of).toList()))))));
|
||||
}
|
||||
List<Query> ownMatches = buildOwnFieldMatchQueries(searchTerm, tableConfigs, allowedTables);
|
||||
Query match = ownMatches.isEmpty() ? snippetMatch
|
||||
: Query.of(q -> q.bool(b -> b.should(snippetMatch).should(ownMatches).minimumShouldMatch("1")));
|
||||
ownMatches.addAll(buildFilteredSnippetQueries(searchTerm, tableConfigs, allowedTables));
|
||||
Query safeSnippetMatch = snippetMatch;
|
||||
Query match = ownMatches.isEmpty() ? safeSnippetMatch
|
||||
: Query.of(q -> q.bool(b -> b.should(safeSnippetMatch).should(ownMatches).minimumShouldMatch("1")));
|
||||
|
||||
BoolQuery.Builder boolBuilder = new BoolQuery.Builder()
|
||||
.must(match)
|
||||
@@ -955,6 +971,36 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** For tables with excluded fields, match only attributable field values.
|
||||
** Old combined text may still contain fields that became classified later.
|
||||
*******************************************************************************/
|
||||
private List<Query> buildFilteredSnippetQueries(String term, List<QuickSearchableTableConfig> configs, Collection<String> allowedTables)
|
||||
{
|
||||
List<Query> queries = new ArrayList<>();
|
||||
for(QuickSearchableTableConfig config : configs == null ? List.<QuickSearchableTableConfig>of() : configs)
|
||||
{
|
||||
if(!allowedTables.contains(config.getTableName()) || !QuickSearchFieldRules.requiresFieldFiltering(config.getTableName()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var table = QuickSearchFieldRules.lookupTable(config.getTableName());
|
||||
List<String> fields = QuickSearchFieldRules.indexableFields(table, config.getSearchableFields()).stream()
|
||||
.filter(config::isIncludedInSnippet).map(f -> PATH_PREFIX + "." + f).toList();
|
||||
if(fields.isEmpty() || QuickSearchFieldRules.isProtected(table, table.getPrimaryKeyField()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
queries.add(Query.of(q -> q.bool(b -> b
|
||||
.filter(f -> f.term(t -> t.field("sourceTable").value(FieldValue.of(config.getTableName()))))
|
||||
.must(m -> m.multiMatch(mm -> mm.query(term).fields(fields).type(TextQueryType.CrossFields)
|
||||
.operator(Operator.And).lenient(true))))));
|
||||
}
|
||||
return (queries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Matches for the fields that are not in searchableText, each limited to
|
||||
** its own table: an EXACT field matches the whole (lower-cased) term, any
|
||||
@@ -965,7 +1011,7 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
List<Query> queries = new ArrayList<>();
|
||||
for(QuickSearchableTableConfig tableConfig : tableConfigs == null ? List.<QuickSearchableTableConfig>of() : tableConfigs)
|
||||
{
|
||||
if(!allowedTables.contains(tableConfig.getTableName()) || tableConfig.isAllPlainFields())
|
||||
if(QuickSearchFieldRules.hasProtectedPrimaryKey(tableConfig.getTableName()) || !allowedTables.contains(tableConfig.getTableName()) || tableConfig.isAllPlainFields())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -973,7 +1019,8 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
Query inTable = Query.of(q -> q.term(t -> t.field("sourceTable").value(FieldValue.of(tableConfig.getTableName()))));
|
||||
for(String field : tableConfig.getSearchableFields() == null ? List.<String>of() : tableConfig.getSearchableFields())
|
||||
{
|
||||
if(tableConfig.isIncludedInSnippet(field))
|
||||
if(tableConfig.isIncludedInSnippet(field)
|
||||
|| (QuickSearchFieldRules.lookupTable(tableConfig.getTableName()) != null && !QuickSearchFieldRules.isIndexable(QuickSearchFieldRules.lookupTable(tableConfig.getTableName()), field)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1019,7 +1066,7 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
|
||||
for(QuickSearchableTableConfig tableConfig : tableConfigs)
|
||||
{
|
||||
if(!allowedTables.contains(tableConfig.getTableName()))
|
||||
if(QuickSearchFieldRules.hasProtectedPrimaryKey(tableConfig.getTableName()) || !allowedTables.contains(tableConfig.getTableName()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1028,6 +1075,10 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
List<String> fields = tableConfig.getSearchableFields() == null ? List.of() : tableConfig.getSearchableFields();
|
||||
for(String field : fields)
|
||||
{
|
||||
if(QuickSearchFieldRules.lookupTable(tableConfig.getTableName()) != null && !QuickSearchFieldRules.isIndexable(QuickSearchFieldRules.lookupTable(tableConfig.getTableName()), field))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
float boost = (weights == null || weights.get(field) == null) ? 1f : weights.get(field).floatValue();
|
||||
QuickSearchFieldAnalyzer analyzer = tableConfig.getAnalyzer(field);
|
||||
if(analyzer == QuickSearchFieldAnalyzer.EXACT)
|
||||
|
||||
@@ -132,7 +132,7 @@ public abstract class AbstractIndexingStep implements BackendStep
|
||||
protected List<QuickSearchableTableConfig> getDiscoveredTables()
|
||||
{
|
||||
List<QuickSearchableTableConfig> tables = QuickSearchQBitContext.getDiscoveredTables();
|
||||
return (tables == null ? Collections.emptyList() : tables);
|
||||
return (tables == null ? Collections.emptyList() : tables.stream().filter(t -> !QuickSearchFieldRules.hasProtectedPrimaryKey(t.getTableName())).toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ public abstract class AbstractIndexingStep implements BackendStep
|
||||
*******************************************************************************/
|
||||
protected QuickSearchableTableConfig getTableConfig(String tableName)
|
||||
{
|
||||
return (QuickSearchQBitContext.getTableConfig(tableName));
|
||||
return (QuickSearchFieldRules.hasProtectedPrimaryKey(tableName) ? null : QuickSearchQBitContext.getTableConfig(tableName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchConfigWrites;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchFieldRules;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchFailedEvent;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndex;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndexRun;
|
||||
@@ -506,6 +507,11 @@ public class BasepullIndexStep extends AbstractIndexingStep
|
||||
|
||||
for(Map.Entry<String, List<QRecord>> entry : deleteByTable.entrySet())
|
||||
{
|
||||
if(QuickSearchFieldRules.hasProtectedPrimaryKey(entry.getKey()))
|
||||
{
|
||||
markReplayOutcome(entry.getValue(), false, List.of("The primary key cannot be indexed"), succeeded, failed);
|
||||
continue;
|
||||
}
|
||||
List<QRecord> toDelete = entry.getValue();
|
||||
try
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchFieldRules;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchFieldRole;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
@@ -103,6 +104,10 @@ public class IndexingUtils
|
||||
|
||||
for(String fieldName : fields)
|
||||
{
|
||||
if(table != null && !QuickSearchFieldRules.isIndexable(table, fieldName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String value = displayValue(record, fieldName, maxFieldLength);
|
||||
if(value == null)
|
||||
{
|
||||
@@ -157,7 +162,7 @@ public class IndexingUtils
|
||||
*******************************************************************************/
|
||||
public static OpenSearchDocument buildDocument(QRecord record, String tableName, String primaryKeyField, List<String> fields, Map<String, Integer> fieldWeights, Map<String, Boolean> includeLabels)
|
||||
{
|
||||
return (buildDocument(record, tableName, primaryKeyField, fields, includeLabels, lookupTable(tableName), null, null));
|
||||
return (buildDocument(record, tableName, primaryKeyField, fields, includeLabels, lookupTable(tableName), null, null, null));
|
||||
}
|
||||
|
||||
|
||||
@@ -165,13 +170,14 @@ public class IndexingUtils
|
||||
/*******************************************************************************
|
||||
** Build a document for a configured table: display values, label fallback,
|
||||
** truncation, and an external version from the basepull timestamp field.
|
||||
** Returns null when the record has no primary key.
|
||||
** Returns null when metadata is unavailable or the primary key is missing
|
||||
** or excluded by the field policy.
|
||||
*******************************************************************************/
|
||||
public static OpenSearchDocument buildDocument(QRecord record, QuickSearchableTableConfig tableConfig)
|
||||
{
|
||||
QTableMetaData table = lookupTable(tableConfig.getTableName());
|
||||
OpenSearchDocument doc = buildDocument(record, tableConfig.getTableName(), tableConfig.getPrimaryKeyField(), tableConfig.getSearchableFields(),
|
||||
tableConfig.getFieldIncludeLabels(), table, tableConfig.getMaxFieldLength(), tableConfig.getBasepullTimestampField());
|
||||
tableConfig.getFieldIncludeLabels(), table, tableConfig.getMaxFieldLength(), tableConfig.getBasepullTimestampField(), tableConfig.getRecordLabelFields());
|
||||
|
||||
if(doc != null && tableConfig.getRecordLabelFormat() != null && tableConfig.getRecordLabelFields() != null)
|
||||
{
|
||||
@@ -199,7 +205,7 @@ public class IndexingUtils
|
||||
*******************************************************************************/
|
||||
static void applyRolesAndAnalyzers(OpenSearchDocument doc, QRecord record, QuickSearchableTableConfig tableConfig, QTableMetaData table)
|
||||
{
|
||||
List<String> fields = tableConfig.getSearchableFields() == null ? List.of() : tableConfig.getSearchableFields();
|
||||
List<String> fields = table == null ? List.of() : QuickSearchFieldRules.indexableFields(table, tableConfig.getSearchableFields());
|
||||
if(!tableConfig.isAllPlainFields())
|
||||
{
|
||||
List<String> snippetFields = new ArrayList<>();
|
||||
@@ -299,8 +305,14 @@ public class IndexingUtils
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static OpenSearchDocument buildDocument(QRecord record, String tableName, String primaryKeyField, List<String> fields, Map<String, Boolean> includeLabels,
|
||||
QTableMetaData table, Integer maxFieldLength, String timestampField)
|
||||
QTableMetaData table, Integer maxFieldLength, String timestampField, List<String> recordLabelFields)
|
||||
{
|
||||
if(table == null || primaryKeyField == null || table.getField(primaryKeyField) == null || QuickSearchFieldRules.isProtected(table, primaryKeyField))
|
||||
{
|
||||
QuickSearchFieldRules.warnExcludedFields(table);
|
||||
return (null);
|
||||
}
|
||||
fields = QuickSearchFieldRules.indexableFields(table, fields);
|
||||
String recordId = record.getValueString(primaryKeyField);
|
||||
if(recordId == null)
|
||||
{
|
||||
@@ -318,7 +330,15 @@ public class IndexingUtils
|
||||
}
|
||||
|
||||
String recordLabel = record.getRecordLabel();
|
||||
if(!StringUtils.hasContent(recordLabel) && table != null)
|
||||
boolean protectedLabel = table.getRecordLabelFields() != null
|
||||
&& table.getRecordLabelFields().stream().anyMatch(f -> QuickSearchFieldRules.isProtected(table, f));
|
||||
if(protectedLabel || (recordLabelFields != null && recordLabelFields.stream().anyMatch(f -> QuickSearchFieldRules.isProtected(table, f))))
|
||||
{
|
||||
recordLabel = null;
|
||||
record = new QRecord(record);
|
||||
record.setRecordLabel(null);
|
||||
}
|
||||
if(!StringUtils.hasContent(recordLabel) && !protectedLabel && table.getRecordLabelFields() != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -331,7 +351,7 @@ public class IndexingUtils
|
||||
}
|
||||
|
||||
Long version = null;
|
||||
if(timestampField != null)
|
||||
if(timestampField != null && !QuickSearchFieldRules.isProtected(table, timestampField))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -361,6 +381,12 @@ public class IndexingUtils
|
||||
*******************************************************************************/
|
||||
private static String formatLabel(QRecord record, QuickSearchableTableConfig tableConfig, String fallback)
|
||||
{
|
||||
QTableMetaData table = lookupTable(tableConfig.getTableName());
|
||||
if(tableConfig.getRecordLabelFields().stream().anyMatch(f -> QuickSearchFieldRules.isProtected(table, f)))
|
||||
{
|
||||
QuickSearchFieldRules.warnExcludedFields(table);
|
||||
return (fallback);
|
||||
}
|
||||
try
|
||||
{
|
||||
Object[] labelValues = new Object[tableConfig.getRecordLabelFields().size()];
|
||||
@@ -373,13 +399,42 @@ public class IndexingUtils
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("recordLabelFormat could not be applied; using default label", e, logPair("tableName", tableConfig.getTableName()));
|
||||
LOG.warn("recordLabelFormat could not be applied; using default label", logPair("tableName", tableConfig.getTableName()));
|
||||
return (fallback);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rebuild result presentation from allowed field values, discarding old
|
||||
** aggregate labels, snippets and display values that may contain secrets.
|
||||
*******************************************************************************/
|
||||
public static OpenSearchDocument filterResult(OpenSearchDocument source, QuickSearchableTableConfig tableConfig)
|
||||
{
|
||||
if(!QuickSearchFieldRules.requiresFieldFiltering(source.getSourceTable()))
|
||||
{
|
||||
return (source);
|
||||
}
|
||||
QTableMetaData table = lookupTable(source.getSourceTable());
|
||||
if(tableConfig == null || QuickSearchFieldRules.isProtected(table, table.getPrimaryKeyField()))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QRecord record = new QRecord().withValue(table.getPrimaryKeyField(), source.getRecordId());
|
||||
for(String field : QuickSearchFieldRules.indexableFields(table, tableConfig.getSearchableFields()))
|
||||
{
|
||||
Object value = source.getFieldValues() == null ? null : source.getFieldValues().get(field);
|
||||
if(value instanceof String text)
|
||||
{
|
||||
record.setValue(field, text);
|
||||
}
|
||||
}
|
||||
return (buildDocument(record, tableConfig));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -132,6 +132,66 @@ components:
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type: "object"
|
||||
QuickSearchSuggestGroupV1:
|
||||
nullable: false
|
||||
properties:
|
||||
hasMore:
|
||||
description: "An extra accessible item was found in the bounded batch. False\
|
||||
\ does not prove the index has no further matches."
|
||||
nullable: false
|
||||
type: "boolean"
|
||||
items:
|
||||
description: "At most limitPerTable accessible items in per-table relevance\
|
||||
\ order."
|
||||
items:
|
||||
$ref: "#/components/schemas/QuickSearchSuggestItemV1"
|
||||
nullable: false
|
||||
type: "array"
|
||||
tableLabel:
|
||||
description: "Source table label, falling back to its metadata name."
|
||||
nullable: false
|
||||
type: "string"
|
||||
tableName:
|
||||
description: "Source table metadata name."
|
||||
nullable: false
|
||||
type: "string"
|
||||
type: "object"
|
||||
QuickSearchSuggestItemV1:
|
||||
nullable: false
|
||||
properties:
|
||||
label:
|
||||
description: "Filtered record label, falling back to tableLabel #recordId\
|
||||
\ when blank."
|
||||
nullable: false
|
||||
type: "string"
|
||||
recordId:
|
||||
description: "Source record primary key."
|
||||
nullable: false
|
||||
type: "string"
|
||||
subtitle:
|
||||
description: "Already-filtered result subtitle; null when absent."
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
QuickSearchSuggestV1:
|
||||
nullable: false
|
||||
properties:
|
||||
groups:
|
||||
description: "Readable tables ordered by best accessible score; at most\
|
||||
\ maxTables."
|
||||
items:
|
||||
$ref: "#/components/schemas/QuickSearchSuggestGroupV1"
|
||||
nullable: false
|
||||
type: "array"
|
||||
query:
|
||||
description: "Search term after trimming and collapsing whitespace."
|
||||
nullable: false
|
||||
type: "string"
|
||||
truncated:
|
||||
description: "More tables had accessible hits than maxTables."
|
||||
nullable: false
|
||||
type: "boolean"
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
in: "cookie"
|
||||
@@ -140,7 +200,7 @@ components:
|
||||
info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "Quick Search administrative status"
|
||||
description: "Quick Search administration and suggestions"
|
||||
title: "QQQ Middleware API extension: quick-search"
|
||||
version: "1.0.0"
|
||||
openapi: "3.0.3"
|
||||
@@ -181,3 +241,77 @@ paths:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Get Quick Search status"
|
||||
/qqq/v1/qbit/quick-search/suggest:
|
||||
get:
|
||||
description: "Requires a signed-in session, not Quick Search admin permission.\
|
||||
\ Results inherit table READ permissions, configured record security locks\
|
||||
\ and protected-field exclusions. One bounded candidate batch per table; no\
|
||||
\ extra scans. Unavailable search and normalized terms shorter than two characters\
|
||||
\ return empty groups."
|
||||
operationId: "getQuickSearchSuggestions"
|
||||
parameters:
|
||||
- description: "Search text: trim and collapse whitespace before applying the\
|
||||
\ 2..100 character range. Shorter terms return empty groups; longer terms\
|
||||
\ return 400."
|
||||
in: "query"
|
||||
name: "q"
|
||||
required: true
|
||||
schema:
|
||||
description: "Normalized search term."
|
||||
nullable: false
|
||||
type: "string"
|
||||
- description: "Items per table: integer 1..10, default 5."
|
||||
in: "query"
|
||||
name: "limitPerTable"
|
||||
required: false
|
||||
schema:
|
||||
description: "Maximum items per table; default 5, maximum 10."
|
||||
nullable: false
|
||||
type: "integer"
|
||||
- description: "Groups to return: integer 1..20, default 8."
|
||||
in: "query"
|
||||
name: "maxTables"
|
||||
required: false
|
||||
schema:
|
||||
description: "Maximum groups; default 8, maximum 20."
|
||||
nullable: false
|
||||
type: "integer"
|
||||
- description: "Optional comma-separated table metadata names. Unknown, disabled\
|
||||
\ and unreadable tables are omitted."
|
||||
in: "query"
|
||||
name: "tables"
|
||||
required: false
|
||||
schema:
|
||||
description: "Table-name filter; omitted means all eligible tables."
|
||||
nullable: false
|
||||
type: "string"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
description: "Missing q, normalized term longer than 100 characters, or\
|
||||
\ invalid limits"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
description: "Session permission denied"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchSuggestV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Get Quick Search suggestions"
|
||||
@@ -54,7 +54,13 @@ class QuickSearchApiContractTest
|
||||
"mvnw.cmd -Dtest=QuickSearchApiContractTest -Dquicksearch.openapi.update=true test")).isEmpty();
|
||||
var model = version.generateExtensionOpenAPIModel("qqq", extension);
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("QuickSearch"));
|
||||
assertThat(model.getPaths().keySet()).containsExactly("/qqq/v1/qbit/quick-search/status");
|
||||
assertThat(extension.getSpecs()).hasSize(1);
|
||||
assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder("/qqq/v1/qbit/quick-search/status", "/qqq/v1/qbit/quick-search/suggest");
|
||||
assertThat(extension.getSpecs()).hasSize(2);
|
||||
assertThat(generated).contains("getQuickSearchSuggestions", "QuickSearchSuggestV1", "QuickSearchSuggestGroupV1", "QuickSearchSuggestItemV1");
|
||||
var schemas = model.getComponents().getSchemas();
|
||||
assertThat(schemas.get("QuickSearchSuggestV1").getProperties()).containsOnlyKeys("query", "groups", "truncated");
|
||||
assertThat(schemas.get("QuickSearchSuggestGroupV1").getProperties()).containsOnlyKeys("tableName", "tableLabel", "hasMore", "items");
|
||||
assertThat(schemas.get("QuickSearchSuggestItemV1").getProperties()).containsOnlyKeys("recordId", "label", "subtitle");
|
||||
assertThat(schemas.get("QuickSearchSuggestItemV1").getProperties().get("subtitle").getNullable()).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -362,8 +362,27 @@ class QuickSearchStatusTest
|
||||
assertThat(anonymous.statusCode()).as(anonymous.body()).isEqualTo(403);
|
||||
var denied = client.send(HttpRequest.newBuilder(URI.create(url)).header("Cookie", "sessionUUID=reader-" + suffix).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(denied.statusCode()).as(denied.body()).isEqualTo(403);
|
||||
String suggestUrl = "http://127.0.0.1:" + port + "/qqq/v1/qbit/quick-search/suggest";
|
||||
for(String query : List.of("?q=x", "?q=%20x%20", "?q=wi&tables=unknown", "?q=wi&tables=", "?q=wi"))
|
||||
{
|
||||
var suggestions = client.send(HttpRequest.newBuilder(URI.create(suggestUrl + query))
|
||||
.header("Cookie", "sessionUUID=reader-" + suffix).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(suggestions.statusCode()).as(suggestions.body()).isEqualTo(200);
|
||||
var suggestionJson = new ObjectMapper().readTree(suggestions.body());
|
||||
assertThat(suggestionJson.path("groups").isArray()).isTrue();
|
||||
assertThat(suggestionJson.path("groups").size()).isZero();
|
||||
assertThat(suggestionJson.path("truncated").asBoolean()).isFalse();
|
||||
}
|
||||
for(String query : List.of("", "?q=" + "x".repeat(101), "?q=wi&limitPerTable=no", "?q=wi&limitPerTable=11", "?q=wi&maxTables=21"))
|
||||
{
|
||||
var invalid = client.send(HttpRequest.newBuilder(URI.create(suggestUrl + query))
|
||||
.header("Cookie", "sessionUUID=reader-" + suffix).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(invalid.statusCode()).as(invalid.body()).isEqualTo(400);
|
||||
assertThat(new ObjectMapper().readTree(invalid.body()).path("error").isTextual()).isTrue();
|
||||
}
|
||||
var openapi = client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/qqq/v1/openapi.yaml")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(openapi.body()).contains("/qqq/v1/qbit/quick-search/status", "QuickSearchStatusV1");
|
||||
assertThat(openapi.body()).contains("/qqq/v1/qbit/quick-search/status", "QuickSearchStatusV1",
|
||||
"/qqq/v1/qbit/quick-search/suggest", "getQuickSearchSuggestions", "QuickSearchSuggestV1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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 java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.IntStream;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchSuggest;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchSuggestService;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument;
|
||||
import com.kingsrook.qbits.quicksearch.processes.IndexingUtils;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
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.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
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.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
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;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.spy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Actual edge-ngram matches, grouping, permissions and classified stale documents.
|
||||
*******************************************************************************/
|
||||
@ExtendWith(RequiresDockerCondition.class)
|
||||
@Testcontainers
|
||||
class QuickSearchSuggestIntegrationTest extends BaseQuickSearchTest
|
||||
{
|
||||
@Container
|
||||
static GenericContainer<?> opensearch = new GenericContainer<>(System.getProperty("opensearch.test.image", "opensearchproject/opensearch:2.19.6"))
|
||||
.withExposedPorts(9200)
|
||||
.withEnv("discovery.type", "single-node")
|
||||
.withEnv("DISABLE_SECURITY_PLUGIN", "true")
|
||||
.withEnv("DISABLE_INSTALL_DEMO_CONFIG", "true")
|
||||
.waitingFor(Wait.forHttp("/_cluster/health").forStatusCode(200));
|
||||
|
||||
private QuickSearchRuntime runtime;
|
||||
private List<QuickSearchableTableConfig> tables;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use real indexing and search over two memory-backed source tables.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
QTableMetaData first = QContext.getQInstance().getTable(TEST_ENTITY_TABLE);
|
||||
first.withLabel("Products").withRecordLabelFields("name").withRecordLabelFormat("%s");
|
||||
first.withField(new QFieldMetaData("personal", QFieldType.STRING));
|
||||
QTableMetaData second = first.clone().withName("other").withLabel("Other Products");
|
||||
QContext.getQInstance().addTable(second);
|
||||
tables = List.of(table(TEST_ENTITY_TABLE), table("other"));
|
||||
QuickSearchQBitConfig config = QuickSearchQBitContext.getConfig()
|
||||
.withOpensearchHost(opensearch.getHost()).withOpensearchPort(opensearch.getMappedPort(9200))
|
||||
.withOpensearchIndexName("suggest-" + UUID.randomUUID()).withEnableRealTimeIndexing(false);
|
||||
runtime = spy(new QuickSearchRuntime(config));
|
||||
runtime.setDiscoveredTables(tables);
|
||||
doReturn(tables).when(runtime).getDiscoveredTables();
|
||||
config.setRuntime(runtime);
|
||||
QContext.getQInstance().addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("quick-search").withVersion("test").withConfig(config));
|
||||
runtime.getClient().ensureIndexExists();
|
||||
for(QuickSearchableTableConfig table : tables)
|
||||
{
|
||||
List<QRecord> records = IntStream.rangeClosed(1, 12).mapToObj(id -> new QRecord()
|
||||
.withValue("id", id).withValue("name", "Blue Widget " + id).withValue("description", "Public details")
|
||||
.withValue("personal", "PII-CANARY-7f3c")).toList();
|
||||
new InsertAction().execute(new InsertInput(table.getTableName()).withRecords(records));
|
||||
List<OpenSearchDocument> documents = records.stream().map(record -> IndexingUtils.buildDocument(record, table)).toList();
|
||||
runtime.getClient().indexDocuments(documents, 100);
|
||||
}
|
||||
runtime.getClient().refreshIndex();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit title and subtitle roles exercise the indexed presentation fields.
|
||||
*******************************************************************************/
|
||||
private QuickSearchableTableConfig table(String name)
|
||||
{
|
||||
return (new QuickSearchableTableConfig().withTableName(name).withPrimaryKeyField("id")
|
||||
.withSearchableFields(List.of("name", "description", "personal")).withEnabledByDefault(true)
|
||||
.withRecordLabelFields(List.of("name")).withRecordLabelFormat("%s")
|
||||
.withFieldRoles(Map.of("name", "TITLE", "description", "SUBTITLE", "personal", "DISPLAY")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Each test owns its alias and closes its transport.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prefix of the final word matches through the unchanged OpenSearch query.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void prefixGroupsAndCaps() throws Exception
|
||||
{
|
||||
QuickSearchSuggest result = QuickSearchSuggestService.suggest(" Blue Wid ", 2, 1, null);
|
||||
assertThat(result.query()).isEqualTo("Blue Wid");
|
||||
assertThat(result.groups()).hasSize(1);
|
||||
assertThat(result.truncated()).isTrue();
|
||||
QuickSearchSuggest.Group group = result.groups().get(0);
|
||||
assertThat(group.items()).hasSize(2);
|
||||
assertThat(group.hasMore()).isTrue();
|
||||
assertThat(group.items()).allSatisfy(item ->
|
||||
{
|
||||
assertThat(item.label()).startsWith("Blue Widget ");
|
||||
assertThat(item.subtitle()).isEqualTo("Public details");
|
||||
});
|
||||
assertThat(new ObjectMapper().writeValueAsString(result)).doesNotContain("highlight", "displayValues", "score");
|
||||
QuickSearchSuggest filtered = QuickSearchSuggestService.suggest("Blue Wid", 10, 20, TEST_ENTITY_TABLE);
|
||||
assertThat(filtered.groups()).extracting(QuickSearchSuggest.Group::tableName).containsExactly(TEST_ENTITY_TABLE);
|
||||
assertThat(filtered.groups().get(0).items()).hasSize(10);
|
||||
assertThat(filtered.groups().get(0).hasMore()).isTrue();
|
||||
assertThat(filtered.truncated()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Search cannot expose unreadable tables or rows excluded by actual QQQ locks.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableAndRecordSecurity() throws Exception
|
||||
{
|
||||
QContext.getQInstance().getTable("other").withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS));
|
||||
QContext.getQInstance().addSecurityKeyType(new QSecurityKeyType().withName("entity"));
|
||||
QContext.getQInstance().getTable(TEST_ENTITY_TABLE).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("entity").withFieldName("id"));
|
||||
QContext.getQSession().withSecurityKeyValue("entity", 2);
|
||||
QuickSearchSuggest result = QuickSearchSuggestService.suggest("Blue Wid", 10, null, null);
|
||||
assertThat(result.groups()).extracting(QuickSearchSuggest.Group::tableName).containsExactly(TEST_ENTITY_TABLE);
|
||||
assertThat(result.groups().get(0).items()).extracting(QuickSearchSuggest.Item::recordId).containsExactly("2");
|
||||
assertThat(result.groups().get(0).hasMore()).isFalse();
|
||||
assertThat(result.truncated()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classification changes protect old documents before a rebuild is performed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void classifiedFieldsNeverMatchOrReturn() throws Exception
|
||||
{
|
||||
OpenSearchDocument stale = new OpenSearchDocument().withSourceTable(TEST_ENTITY_TABLE).withRecordId("1")
|
||||
.withRecordLabel("PHI-CANARY-7f3c").withSubtitle("PHI-CANARY-7f3c").withSearchableText("PHI-CANARY-7f3c PII-CANARY-7f3c")
|
||||
.withFieldValues(Map.of("name", "Public widget", "description", "PHI-CANARY-7f3c", "personal", "PII-CANARY-7f3c"))
|
||||
.withDisplayValues(Map.of("Personal", "PII-CANARY-7f3c"));
|
||||
runtime.getClient().indexDocuments(List.of(stale), 100);
|
||||
runtime.getClient().refreshIndex();
|
||||
QTableMetaData metadata = QContext.getQInstance().getTable(TEST_ENTITY_TABLE);
|
||||
metadata.getField("description").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI));
|
||||
metadata.getField("personal").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
for(String term : List.of("PHI-CANARY", "PII-CANARY"))
|
||||
{
|
||||
assertThat(QuickSearchSuggestService.suggest(term, null, null, TEST_ENTITY_TABLE).groups()).isEmpty();
|
||||
}
|
||||
QuickSearchSuggest result = QuickSearchSuggestService.suggest("Public wid", null, null, TEST_ENTITY_TABLE);
|
||||
assertThat(result.groups()).hasSize(1);
|
||||
assertThat(result.groups().get(0).items()).hasSize(1);
|
||||
assertThat(result.groups().get(0).items().get(0).label()).isEqualTo("Public widget");
|
||||
assertThat(result.groups().get(0).items().get(0).subtitle()).isNull();
|
||||
assertThat(new ObjectMapper().writeValueAsString(result)).doesNotContain("PHI-CANARY", "PII-CANARY");
|
||||
metadata.getField("id").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
assertThat(QuickSearchSuggestService.suggest("Public wid", null, null, TEST_ENTITY_TABLE).groups()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.IntStream;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchSuggest;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchSuggestService;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchOpenSearchClient;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
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.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.opensearch.client.opensearch.core.SearchResponse;
|
||||
import org.opensearch.client.opensearch.core.search.Hit;
|
||||
import org.opensearch.client.opensearch.core.search.TotalHitsRelation;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise real search filtering and bounded requests with a stubbed transport.
|
||||
*******************************************************************************/
|
||||
class QuickSearchSuggestTest extends BaseQuickSearchTest
|
||||
{
|
||||
private QuickSearchRuntime runtime;
|
||||
private QuickSearchQBitConfig config;
|
||||
private QuickSearchOpenSearchClient client;
|
||||
private List<QuickSearchableTableConfig> tables;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register an instance runtime while keeping the real permission/search path.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
config = QuickSearchQBitContext.getConfig();
|
||||
client = QuickSearchQBitContext.getClient();
|
||||
runtime = spy(new QuickSearchRuntime(config));
|
||||
tables = new ArrayList<>(QuickSearchQBitContext.getDiscoveredTables());
|
||||
doReturn(client).when(runtime).getClient();
|
||||
doReturn(tables).when(runtime).getDiscoveredTables();
|
||||
doReturn(true).when(runtime).isTableEnabled(anyString());
|
||||
config.setRuntime(runtime);
|
||||
QContext.getQInstance().addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("quick-search").withVersion("test").withConfig(config));
|
||||
QContext.getQInstance().getTable(TEST_ENTITY_TABLE).withLabel("Test Entity");
|
||||
insert(TEST_ENTITY_TABLE);
|
||||
stub(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate enough source rows for actual QueryAction authorization.
|
||||
*******************************************************************************/
|
||||
private void insert(String table) throws Exception
|
||||
{
|
||||
new InsertAction().execute(new InsertInput(table).withRecords(IntStream.rangeClosed(1, 30)
|
||||
.mapToObj(id -> new QRecord().withValue("id", id).withValue("name", "Widget " + id)).toList()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add another configured, readable table.
|
||||
*******************************************************************************/
|
||||
private void addTable(String name) throws Exception
|
||||
{
|
||||
QTableMetaData metadata = QContext.getQInstance().getTable(TEST_ENTITY_TABLE).clone();
|
||||
metadata.setName(name);
|
||||
metadata.setLabel(name);
|
||||
QContext.getQInstance().addTable(metadata);
|
||||
tables.add(new QuickSearchableTableConfig().withTableName(name).withPrimaryKeyField("id").withSearchableFields(List.of("name")).withEnabledByDefault(true));
|
||||
insert(name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Honor fetch bounds and give later table names higher scores for ranking.
|
||||
*******************************************************************************/
|
||||
private void stub(Integer total) throws Exception
|
||||
{
|
||||
doAnswer(invocation ->
|
||||
{
|
||||
String table = ((List<String>) invocation.getArgument(1)).get(0);
|
||||
Integer size = invocation.getArgument(2);
|
||||
List<Hit<OpenSearchDocument>> hits = IntStream.rangeClosed(1, Math.min(total, size)).mapToObj(id ->
|
||||
new Hit.Builder<OpenSearchDocument>().index("test").id(table + ":" + id).score((table.equals(TEST_ENTITY_TABLE) ? 10.0 : 100.0) - id)
|
||||
.source(new OpenSearchDocument().withSourceTable(table).withRecordId(String.valueOf(id)).withRecordLabel("Widget " + id)
|
||||
.withSubtitle("Public subtitle").withDisplayValues(Map.of("Extra", "not exposed")))
|
||||
.highlight(Map.of("searchableText", List.of("<em>Widget</em>"))).build()).toList();
|
||||
return (new SearchResponse.Builder<OpenSearchDocument>().took(1).timedOut(false).shards(sh -> sh.total(1).successful(1).failed(0))
|
||||
.hits(h -> h.hits(hits).total(t -> t.value(total).relation(TotalHitsRelation.Eq))).build());
|
||||
}).when(client).search(anyString(), anyList(), anyInt(), anyInt(), anyList(), any());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalize, group, preserve relevance order and expose only the wire fields.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void groupedResultsAndWireShape() throws Exception
|
||||
{
|
||||
addTable("other");
|
||||
QuickSearchSuggest result = QuickSearchSuggestService.suggest(" blue wid ", 2, 1, null);
|
||||
assertThat(result.query()).isEqualTo("blue wid");
|
||||
assertThat(result.truncated()).isTrue();
|
||||
assertThat(result.groups()).hasSize(1);
|
||||
QuickSearchSuggest.Group group = result.groups().get(0);
|
||||
assertThat(group.tableName()).isEqualTo("other");
|
||||
assertThat(group.hasMore()).isTrue();
|
||||
assertThat(group.items()).extracting(QuickSearchSuggest.Item::recordId).containsExactly("1", "2");
|
||||
assertThat(group.items().get(0).label()).isEqualTo("Widget 1");
|
||||
assertThat(group.items().get(0).subtitle()).isEqualTo("Public subtitle");
|
||||
assertThat(new ObjectMapper().writeValueAsString(result)).doesNotContain("score", "highlight", "displayValues", "<em>", "not exposed");
|
||||
verify(client).search(eq("blue wid"), eq(List.of("other")), eq(6), eq(0), anyList(), any());
|
||||
verify(client).search(eq("blue wid"), eq(List.of(TEST_ENTITY_TABLE)), eq(6), eq(0), anyList(), any());
|
||||
assertThatThrownBy(() -> result.groups().clear()).isInstanceOf(UnsupportedOperationException.class);
|
||||
assertThatThrownBy(() -> group.items().clear()).isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default and maximum caps include an extra visible item without rescanning.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void defaultAndMaximumCaps() throws Exception
|
||||
{
|
||||
stub(30);
|
||||
for(int i = 0; i < 21; i++)
|
||||
{
|
||||
addTable("table" + i);
|
||||
}
|
||||
QuickSearchSuggest defaults = QuickSearchSuggestService.suggest("wi", null, null, null);
|
||||
assertThat(defaults.groups()).hasSize(8).allSatisfy(group ->
|
||||
{
|
||||
assertThat(group.items()).hasSize(5);
|
||||
assertThat(group.hasMore()).isTrue();
|
||||
});
|
||||
assertThat(defaults.truncated()).isTrue();
|
||||
QuickSearchSuggest maximum = QuickSearchSuggestService.suggest("wi", 10, 20, null);
|
||||
assertThat(maximum.groups()).hasSize(20).allSatisfy(group -> assertThat(group.items()).hasSize(10));
|
||||
assertThat(maximum.truncated()).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table selection, permissions, enabled flags and protected primary keys apply.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableFilters() throws Exception
|
||||
{
|
||||
addTable("other");
|
||||
assertThat(QuickSearchSuggestService.suggest("wi", null, null, " other, other,unknown ").groups())
|
||||
.extracting(QuickSearchSuggest.Group::tableName).containsExactly("other");
|
||||
assertThat(QuickSearchSuggestService.suggest("wi", null, null, " , ").groups()).isEmpty();
|
||||
QContext.getQInstance().getTable("other").withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS));
|
||||
assertThat(QuickSearchSuggestService.suggest("wi", null, null, "other").groups()).isEmpty();
|
||||
doReturn(false).when(runtime).isTableEnabled(TEST_ENTITY_TABLE);
|
||||
assertThat(QuickSearchSuggestService.suggest("wi", null, null, null).groups()).isEmpty();
|
||||
doReturn(true).when(runtime).isTableEnabled(TEST_ENTITY_TABLE);
|
||||
QContext.getQInstance().getTable(TEST_ENTITY_TABLE).getField("id")
|
||||
.withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
assertThat(QuickSearchSuggestService.suggest("wi", null, null, TEST_ENTITY_TABLE).groups()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real record locks remove hits; an empty candidate batch never triggers scans.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recordLocksAndSinglePass() throws Exception
|
||||
{
|
||||
QContext.getQInstance().addSecurityKeyType(new QSecurityKeyType().withName("entity"));
|
||||
QContext.getQInstance().getTable(TEST_ENTITY_TABLE).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("entity").withFieldName("id"));
|
||||
QContext.getQSession().withSecurityKeyValue("entity", 2);
|
||||
QuickSearchSuggest result = QuickSearchSuggestService.suggest("wi", 1, null, null);
|
||||
assertThat(result.groups().get(0).items()).extracting(QuickSearchSuggest.Item::recordId).containsExactly("2");
|
||||
assertThat(result.groups().get(0).hasMore()).isFalse();
|
||||
clearInvocations(client);
|
||||
QContext.getQSession().clearSecurityKeyValues();
|
||||
stub(30);
|
||||
assertThat(QuickSearchSuggestService.suggest("wi", 1, null, null).groups()).isEmpty();
|
||||
verify(client).search(anyString(), anyList(), eq(4), eq(0), anyList(), any());
|
||||
verify(client, never()).search(anyString(), anyList(), anyInt(), eq(4), anyList(), any());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stale PHI/PII labels and subtitles are rebuilt from allowed fields only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void classifiedStaleResultAndFallback() throws Exception
|
||||
{
|
||||
QTableMetaData metadata = QContext.getQInstance().getTable(TEST_ENTITY_TABLE);
|
||||
metadata.getField("description").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI));
|
||||
tables.get(0).withRecordLabelFields(List.of("description")).withFieldRoles(Map.of("description", "SUBTITLE"));
|
||||
OpenSearchDocument stale = new OpenSearchDocument().withSourceTable(TEST_ENTITY_TABLE).withRecordId("1")
|
||||
.withRecordLabel("PHI-CANARY-7f3c").withSubtitle("PHI-CANARY-7f3c").withSearchableText("PHI-CANARY-7f3c")
|
||||
.withFieldValues(Map.of("name", "Public widget", "description", "PHI-CANARY-7f3c"));
|
||||
SearchResponse<OpenSearchDocument> response = new SearchResponse.Builder<OpenSearchDocument>().took(1).timedOut(false)
|
||||
.shards(sh -> sh.total(1).successful(1).failed(0)).hits(h -> h.hits(hit -> hit.index("test").id("1").source(stale))).build();
|
||||
doReturn(response).when(client).search(anyString(), anyList(), anyInt(), anyInt(), anyList(), any());
|
||||
QuickSearchSuggest result = QuickSearchSuggestService.suggest("Public", null, null, null);
|
||||
assertThat(result.groups().get(0).items().get(0).label()).isEqualTo("Test Entity #1");
|
||||
assertThat(result.groups().get(0).items().get(0).subtitle()).isNull();
|
||||
assertThat(new ObjectMapper().writeValueAsString(result)).doesNotContain("PHI-CANARY-7f3c");
|
||||
assertThat(result.truncated()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Input validation runs even when search is unavailable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validationAndShortTerms() throws Exception
|
||||
{
|
||||
for(String term : List.of("", " ", " x "))
|
||||
{
|
||||
assertThat(QuickSearchSuggestService.suggest(term, null, null, null).groups()).isEmpty();
|
||||
}
|
||||
verifyNoInteractions(client);
|
||||
assertThatThrownBy(() -> QuickSearchSuggestService.suggest(null, null, null, null)).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> QuickSearchSuggestService.suggest("x".repeat(101), null, null, null)).isInstanceOf(QBadRequestException.class);
|
||||
for(Integer limit : List.of(0, -1, 11))
|
||||
{
|
||||
assertThatThrownBy(() -> QuickSearchSuggestService.suggest("wi", limit, null, null)).isInstanceOf(QBadRequestException.class);
|
||||
}
|
||||
for(Integer limit : List.of(0, -1, 21))
|
||||
{
|
||||
assertThatThrownBy(() -> QuickSearchSuggestService.suggest("wi", null, limit, null)).isInstanceOf(QBadRequestException.class);
|
||||
}
|
||||
assertThat(QuickSearchSuggestService.suggest(" " + "x".repeat(100) + " ", null, null, null).query()).hasSize(100);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hosts that disable record locks still use one request and visible lookahead.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unlockedSearchAndHostLimit() throws Exception
|
||||
{
|
||||
config.withApplyRecordSecurityLocks(false);
|
||||
QuickSearchSuggest result = QuickSearchSuggestService.suggest("wi", 2, null, null);
|
||||
assertThat(result.groups().get(0).items()).hasSize(2);
|
||||
assertThat(result.groups().get(0).hasMore()).isTrue();
|
||||
verify(client).search(anyString(), anyList(), eq(3), eq(0), anyList(), any());
|
||||
config.withMaxSearchLimit(1);
|
||||
QuickSearchSuggest bounded = QuickSearchSuggestService.suggest("wi", 2, null, null);
|
||||
assertThat(bounded.groups().get(0).items()).hasSize(1);
|
||||
assertThat(bounded.groups().get(0).hasMore()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing runtime/client and failed transports are forgiving; permissions are not.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unavailableAndSessionRequired() throws Exception
|
||||
{
|
||||
config.setRuntime(null);
|
||||
assertThat(QuickSearchSuggestService.suggest("wi", null, null, null).groups()).isEmpty();
|
||||
config.setRuntime(runtime);
|
||||
doReturn(null).when(runtime).getClient();
|
||||
assertThat(QuickSearchSuggestService.suggest("wi", null, null, null).groups()).isEmpty();
|
||||
doThrow(new QException("unavailable")).when(runtime).getClient();
|
||||
assertThat(QuickSearchSuggestService.suggest("wi", null, null, null).groups()).isEmpty();
|
||||
doThrow(new QPermissionDeniedException("denied")).when(runtime).getClient();
|
||||
assertThatThrownBy(() -> QuickSearchSuggestService.suggest("wi", null, null, null)).isInstanceOf(QPermissionDeniedException.class);
|
||||
QContext.init(QContext.getQInstance(), (QSession) null);
|
||||
assertThatThrownBy(() -> QuickSearchSuggestService.suggest("", null, null, null)).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
}
|
||||
+107
@@ -19,8 +19,13 @@ package com.kingsrook.qbits.quicksearch.config;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QCollectingLogger;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
@@ -28,6 +33,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchFailedEvent;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchFieldAnalyzer;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchFieldRole;
|
||||
@@ -47,6 +54,106 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class QuickSearchConfigCustomizersTest extends BaseDataDrivenTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Classification falls back to the table; PUBLIC and unclassified fields
|
||||
** remain searchable. Password, hidden, sensitive and locked fields do not.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testClassifiedAndSecuredFieldRules()
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(TEST_ENTITY_TABLE);
|
||||
assertThat(QuickSearchFieldRules.isIndexable(table, "name")).isTrue();
|
||||
assertThat(QuickSearchFieldRules.isProtected(table, "name")).isFalse();
|
||||
for(DataClassification classification : DataClassification.values())
|
||||
{
|
||||
table.getField("name").withSupplementalMetaData(new AiFieldMetaData().withClassification(classification));
|
||||
assertThat(QuickSearchFieldRules.isIndexable(table, "name")).as(classification.name())
|
||||
.isEqualTo(classification != DataClassification.PII && classification != DataClassification.PHI);
|
||||
assertThat(QuickSearchFieldRules.isProtected(table, "name")).as(classification.name())
|
||||
.isEqualTo(classification == DataClassification.PII || classification == DataClassification.PHI);
|
||||
}
|
||||
table.withSupplementalMetaData(new AiTableMetaData().withClassification(DataClassification.PHI));
|
||||
assertThat(QuickSearchFieldRules.whyNotIndexable(table, "description")).contains("classified PHI");
|
||||
assertThat(QuickSearchFieldRules.isProtected(table, "description")).isTrue();
|
||||
table.getField("name").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PUBLIC));
|
||||
assertThat(QuickSearchFieldRules.isIndexable(table, "name")).isTrue();
|
||||
table.getField("name").setFieldSecurityLock(new FieldSecurityLock());
|
||||
assertThat(QuickSearchFieldRules.isProtected(table, "name")).isTrue();
|
||||
assertThat(QuickSearchFieldRules.whyNotIndexable(table, "name")).contains("security lock");
|
||||
table.getField("name").setFieldSecurityLock(null);
|
||||
table.getField("name").setType(QFieldType.PASSWORD);
|
||||
assertThat(QuickSearchFieldRules.isProtected(table, "name")).isTrue();
|
||||
assertThat(QuickSearchFieldRules.whyNotIndexable(table, "name")).contains("sensitive");
|
||||
table.getField("name").setType(QFieldType.STRING);
|
||||
table.getField("name").setIsHidden(true);
|
||||
assertThat(QuickSearchFieldRules.whyNotIndexable(table, "name")).contains("hidden");
|
||||
assertThat(QuickSearchFieldRules.isProtected(table, "name")).isFalse();
|
||||
for(QFieldType type : List.of(QFieldType.BOOLEAN, QFieldType.BLOB))
|
||||
{
|
||||
table.getField("name").setType(type);
|
||||
assertThat(QuickSearchFieldRules.isProtected(table, "name")).isFalse();
|
||||
}
|
||||
table.getField("name").setType(null);
|
||||
assertThat(QuickSearchFieldRules.isProtected(table, "name")).isFalse();
|
||||
table.getField("name").setIsSensitive(true);
|
||||
assertThat(QuickSearchFieldRules.isProtected(table, "name")).isTrue();
|
||||
assertThat(QuickSearchFieldRules.isProtected(null, "name")).isFalse();
|
||||
assertThat(QuickSearchFieldRules.isProtected(table, null)).isFalse();
|
||||
assertThat(QuickSearchFieldRules.isProtected(table, "missing")).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeated config loads and document writes warn only once per table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testExcludedFieldsWarnOncePerTable()
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(TEST_ENTITY_TABLE);
|
||||
table.getField("description").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI));
|
||||
QCollectingLogger logger = QLogger.activateCollectingLoggerForClass(QuickSearchFieldRules.class);
|
||||
try
|
||||
{
|
||||
for(int i = 0; i < 3; i++)
|
||||
{
|
||||
assertThat(QuickSearchFieldRules.indexableFields(table, List.of("name", "description"))).containsExactly("name");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
QLogger.deactivateCollectingLoggerForClass(QuickSearchFieldRules.class);
|
||||
}
|
||||
assertThat(logger.getCollectedMessages()).singleElement()
|
||||
.satisfies(message -> assertThat(message.getMessage()).contains("dropped fields", TEST_ENTITY_TABLE, "PII/PHI"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Config writes refuse classified fields with a useful message; the same
|
||||
** fields are absent from the picker and default selection.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testClassifiedFieldConfigIsRefused() throws QException
|
||||
{
|
||||
runtime.getConfigStore().tryPrime();
|
||||
Integer indexId = indexRow(TEST_ENTITY_TABLE).getValueInteger("id");
|
||||
QTableMetaData table = QContext.getQInstance().getTable(TEST_ENTITY_TABLE);
|
||||
for(DataClassification classification : List.of(DataClassification.PII, DataClassification.PHI))
|
||||
{
|
||||
String fieldName = classification.name().toLowerCase(java.util.Locale.ROOT);
|
||||
table.withField(new QFieldMetaData(fieldName, QFieldType.STRING)
|
||||
.withSupplementalMetaData(new AiFieldMetaData().withClassification(classification)));
|
||||
assertThat(insertField(indexId, fieldName, 1, null).getErrors()).singleElement()
|
||||
.satisfies(e -> assertThat(e.getMessage()).contains("classified " + classification, "cannot be indexed"));
|
||||
assertThat(QuickSearchFieldRules.indexableFields(table)).extracting(QFieldMetaData::getName).doesNotContain(fieldName);
|
||||
assertThat(QuickSearchFieldRules.defaultFields(table)).extracting(QuickSearchFieldRules.DefaultField::fieldName).doesNotContain(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Field rows: a field must exist, be indexable, not sensitive or hidden,
|
||||
** and not be listed twice; weights 1-1000; known role and analyzer.
|
||||
|
||||
+24
@@ -24,6 +24,8 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
@@ -147,6 +149,28 @@ class QuickSearchRecordChangeListenerTest extends BaseQuickSearchTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A custom search publisher also receives only allowed values, so it cannot
|
||||
** bypass the document builder's policy by indexing the event record.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCustomPublisherReceivesNoClassifiedValues() throws QException
|
||||
{
|
||||
QContext.getQInstance().getTable(TEST_ENTITY_TABLE).getField("description")
|
||||
.withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI));
|
||||
QContext.getQInstance().getTable(TEST_ENTITY_TABLE).withRecordLabelFields("description").withRecordLabelFormat("%s");
|
||||
new InsertAction().execute(new InsertInput(TEST_ENTITY_TABLE).withRecord(new QRecord()
|
||||
.withValue("name", "Public widget").withValue("description", "PHI-CANARY-7f3c")));
|
||||
ArgumentCaptor<List<IndexEvent>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(publisher).publishIndexEvents(captor.capture());
|
||||
QRecord record = captor.getValue().get(0).getRecord();
|
||||
assertThat(record.getValues()).containsEntry("name", "Public widget").doesNotContainKey("description");
|
||||
assertThat(record.getDisplayValue("description")).isNull();
|
||||
assertThat(record.getRecordLabel()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void testAppliesTo_onlyConfiguredTablesWhenEnabled()
|
||||
{
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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.opensearch;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
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.code.QCodeReference;
|
||||
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.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.kingsrook.qbits.quicksearch.BaseQuickSearchTest;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitContext;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.actions.QuickSearchAction;
|
||||
import com.kingsrook.qbits.quicksearch.actions.QuickSearchInput;
|
||||
import com.kingsrook.qbits.quicksearch.listeners.QuickSearchRecordChangeListener;
|
||||
import com.kingsrook.qbits.quicksearch.processes.BasepullIndexStep;
|
||||
import com.kingsrook.qbits.quicksearch.processes.IndexingUtils;
|
||||
import com.kingsrook.qbits.quicksearch.publisher.SynchronousIndexEventPublisher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.opensearch.client.opensearch.core.SearchResponse;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Canary coverage at the backend seam for live writes, batch indexing and
|
||||
** stale search results. No OpenSearch server is required.
|
||||
*******************************************************************************/
|
||||
class ClassifiedDataCanaryTest extends BaseQuickSearchTest
|
||||
{
|
||||
private static final String CANARY = "PHI-CANARY-7f3c";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classified values must never reach any document representation or result,
|
||||
** even with explicit field lists, label formats, roles and analyzer copies.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCanaryAcrossRealtimeBasepullAndResults() throws Exception
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(TEST_ENTITY_TABLE);
|
||||
table.getField("description").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI));
|
||||
table.withField(new QFieldMetaData("personal", QFieldType.STRING)
|
||||
.withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII)));
|
||||
table.withRecordLabelFields("description").withRecordLabelFormat("%s");
|
||||
|
||||
QuickSearchableTableConfig fields = new QuickSearchableTableConfig().withTableName(TEST_ENTITY_TABLE).withPrimaryKeyField("id")
|
||||
.withSearchableFields(List.of("name", "description", "personal")).withEnabledByDefault(true)
|
||||
.withBasepullTimestampField("modifyDate").withBasepullIntervalMinutes(5)
|
||||
.withRecordLabelFields(List.of("description", "personal")).withRecordLabelFormat("%s %s")
|
||||
.withFieldRoles(Map.of("name", "TITLE", "description", "SUBTITLE", "personal", "DISPLAY"))
|
||||
.withFieldAnalyzers(Map.of("description", "ENGLISH", "personal", "EXACT"));
|
||||
QuickSearchOpenSearchClient client = mock(QuickSearchOpenSearchClient.class);
|
||||
List<OpenSearchDocument> captured = new ArrayList<>();
|
||||
doAnswer(invocation ->
|
||||
{
|
||||
List<OpenSearchDocument> documents = invocation.getArgument(0);
|
||||
captured.addAll(documents);
|
||||
return (new BulkIndexResult().withSuccessCount(documents.size()));
|
||||
}).when(client).indexDocuments(anyList(), anyInt());
|
||||
when(client.indexExists()).thenReturn(true);
|
||||
when(client.countAllDocuments()).thenReturn(1_000L);
|
||||
when(client.countDocumentsForTable(anyString())).thenReturn(1L);
|
||||
|
||||
QuickSearchQBitConfig config = QuickSearchQBitContext.getConfig();
|
||||
config.withIndexEventPublisher(new SynchronousIndexEventPublisher(client, List.of(fields), 100));
|
||||
QuickSearchRuntime runtime = spy(new QuickSearchRuntime(config));
|
||||
doReturn(client).when(runtime).getClient();
|
||||
runtime.setDiscoveredTables(List.of(fields));
|
||||
config.setRuntime(runtime);
|
||||
QContext.getQInstance().addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("quick-search").withVersion("test").withConfig(config));
|
||||
QContext.getQInstance().withRecordChangeListener(new QCodeReference(QuickSearchRecordChangeListener.class));
|
||||
QuickSearchQBitContext.setClient(client);
|
||||
QuickSearchQBitContext.setDiscoveredTables(List.of(fields));
|
||||
|
||||
new InsertAction().execute(new InsertInput(TEST_ENTITY_TABLE).withRecord(new QRecord()
|
||||
.withValue("name", "Public widget").withValue("description", CANARY).withValue("personal", CANARY)));
|
||||
assertThat(captured).as("real-time indexing reached the backend").hasSize(1);
|
||||
new BasepullIndexStep().run(new RunBackendStepInput(), new RunBackendStepOutput());
|
||||
assertThat(captured.size()).as("basepull also reached the backend").isGreaterThan(1);
|
||||
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
for(OpenSearchDocument document : captured)
|
||||
{
|
||||
assertThat(mapper.writeValueAsString(document)).doesNotContain(CANARY).contains("Public widget");
|
||||
assertThat(document.getFieldValues()).containsEntry("name", "Public widget").doesNotContainKeys("description", "personal");
|
||||
}
|
||||
|
||||
OpenSearchDocument stale = new OpenSearchDocument().withSourceTable(TEST_ENTITY_TABLE).withRecordId("1")
|
||||
.withRecordLabel(CANARY).withSearchableText(CANARY).withSubtitle(CANARY).withDisplayValues(Map.of("Personal", CANARY))
|
||||
.withFieldValues(Map.of("name", "Public widget", "description", CANARY, "personal", CANARY));
|
||||
SearchResponse<OpenSearchDocument> response = new SearchResponse.Builder<OpenSearchDocument>().took(1).timedOut(false)
|
||||
.shards(sh -> sh.total(1).successful(1).failed(0))
|
||||
.hits(h -> h.hits(hit -> hit.index("test").id("1").source(stale).highlight(Map.of("searchableText", List.of(CANARY))))).build();
|
||||
when(client.search(anyString(), anyList(), anyInt(), anyInt(), anyList(), any())).thenReturn(response);
|
||||
config.setApplyRecordSecurityLocks(false);
|
||||
var result = new QuickSearchAction().execute(new QuickSearchInput().withSearchTerm("Public"));
|
||||
assertThat(result.getResults()).hasSize(1);
|
||||
assertThat(mapper.writeValueAsString(result)).doesNotContain(CANARY).contains("Public widget");
|
||||
|
||||
try(QuickSearchOpenSearchClient queryClient = new QuickSearchOpenSearchClient(config))
|
||||
{
|
||||
String request = queryClient.buildSearchQuery("Public", List.of(TEST_ENTITY_TABLE), List.of(fields), false).toJsonString();
|
||||
assertThat(request).doesNotContain("searchableText", "fieldValues.description", "fieldValuesEnglish.description", "fieldValuesExact.personal", CANARY);
|
||||
assertThat(request).contains("fieldValues.name");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Diagnostic document views must not reveal protected values retained in
|
||||
** old index documents, including analyzer copies and combined labels.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testDiagnosticDocumentReadsOmitClassifiedValues() throws Exception
|
||||
{
|
||||
QContext.getQInstance().getTable(TEST_ENTITY_TABLE).getField("description")
|
||||
.withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI));
|
||||
OpenSearchDocument stale = new OpenSearchDocument().withRecordId("1").withSourceTable(TEST_ENTITY_TABLE)
|
||||
.withRecordLabel(CANARY).withSearchableText(CANARY).withSubtitle(CANARY).withDisplayValues(Map.of("Secret", CANARY))
|
||||
.withFieldValues(Map.of("name", "Public widget", "description", CANARY))
|
||||
.withFieldValuesWord(Map.of("description", CANARY)).withFieldValuesEnglish(Map.of("description", CANARY))
|
||||
.withFieldValuesExact(Map.of("description", CANARY));
|
||||
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
var body = mapper.valueToTree(Map.of("found", true, "_source", stale,
|
||||
"docs", List.of(Map.of("found", true, "_id", TEST_ENTITY_TABLE + ":1", "_source", stale))));
|
||||
QuickSearchOpenSearchClient client = mock(QuickSearchOpenSearchClient.class);
|
||||
when(client.getIndexName()).thenReturn("test");
|
||||
QuickSearchDiagnosticsOperations ops = spy(new QuickSearchDiagnosticsOperations(client));
|
||||
doReturn(new QuickSearchDiagnosticsOperations.RawResponse(200, body)).when(ops).raw(anyString(), anyString(), any());
|
||||
assertThat(ops.getDocument(TEST_ENTITY_TABLE, "1").source().toString()).doesNotContain(CANARY).contains("Public widget");
|
||||
assertThat(ops.getDocuments(TEST_ENTITY_TABLE, List.of("1")).get("1").toString()).doesNotContain(CANARY).contains("Public widget");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recheck metadata at document construction, including primary keys and
|
||||
** untrusted preformatted labels, without depending on config priming.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testTableClassificationAndProtectedPrimaryKey() throws Exception
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(TEST_ENTITY_TABLE);
|
||||
table.getField("description").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI));
|
||||
table.withRecordLabelFields("description").withRecordLabelFormat("%s");
|
||||
QRecord record = new QRecord().withValue("id", 1).withValue("name", "Public widget").withValue("description", CANARY);
|
||||
record.setRecordLabel(CANARY);
|
||||
OpenSearchDocument document = IndexingUtils.buildDocument(record, QuickSearchQBitContext.getDiscoveredTables().get(0));
|
||||
assertThat(document.getRecordLabel()).isNull();
|
||||
assertThat(document.getSearchableText()).isEqualTo("Public widget");
|
||||
table.getField("id").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
assertThat(IndexingUtils.buildDocument(record, QuickSearchQBitContext.getDiscoveredTables().get(0))).isNull();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
@@ -33,8 +35,10 @@ 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.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchFieldRules;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -45,6 +49,143 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class IndexingUtilsTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Document building requires field metadata to apply the index policy.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUpMetadata()
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
for(String tableName : List.of("customers", "person", "product", "order"))
|
||||
{
|
||||
QTableMetaData table = new QTableMetaData().withName(tableName).withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME));
|
||||
for(String field : List.of("name", "description", "firstName", "lastName", "email", "statusId", "quantity", "notes", "sku"))
|
||||
{
|
||||
table.withField(new QFieldMetaData(field, QFieldType.STRING));
|
||||
}
|
||||
instance.addTable(table);
|
||||
}
|
||||
QContext.init(instance, new QSession());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unsupported search fields must not erase labels or rebuild results.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUnprotectedBooleanAndBlobPreserveLabelAndResult()
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable("product");
|
||||
table.withField(new QFieldMetaData("active", QFieldType.BOOLEAN))
|
||||
.withField(new QFieldMetaData("attachment", QFieldType.BLOB))
|
||||
.withRecordLabelFields("name").withRecordLabelFormat("%s");
|
||||
QRecord record = new QRecord().withValue("id", 1).withValue("name", "Widget")
|
||||
.withValue("active", true).withValue("attachment", new byte[] { 1, 2 });
|
||||
record.setRecordLabel("Widget label");
|
||||
QuickSearchableTableConfig config = new QuickSearchableTableConfig().withTableName("product").withPrimaryKeyField("id")
|
||||
.withSearchableFields(List.of("name", "active", "attachment"));
|
||||
|
||||
OpenSearchDocument document = IndexingUtils.buildDocument(record, config);
|
||||
assertThat(document.getRecordLabel()).isEqualTo("Widget label");
|
||||
assertThat(document.getSearchableText()).isEqualTo("Widget");
|
||||
assertThat(document.getFieldValues()).containsExactlyEntriesOf(Map.of("name", "Widget"));
|
||||
assertThat(QuickSearchFieldRules.requiresFieldFiltering("product")).isFalse();
|
||||
assertThat(IndexingUtils.filterResult(document, config)).isSameAs(document);
|
||||
|
||||
record.setRecordLabel(null);
|
||||
assertThat(IndexingUtils.buildDocument(record, config).getRecordLabel()).isEqualTo("Widget");
|
||||
config.withRecordLabelFields(List.of("name", "active")).withRecordLabelFormat("%s: %s");
|
||||
assertThat(IndexingUtils.buildDocument(record, config).getRecordLabel()).isEqualTo("Widget: true");
|
||||
table.withRecordLabelFields("name", "active").withRecordLabelFormat("%s: %s");
|
||||
config.withRecordLabelFormat(null);
|
||||
assertThat(IndexingUtils.buildDocument(record, config).getRecordLabel()).isEqualTo("Widget: true");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A protected field unrelated to the label must not erase a safe label.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testUnrelatedProtectedFieldPreservesLabel()
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable("product");
|
||||
table.withRecordLabelFields("name").withRecordLabelFormat("%s");
|
||||
table.getField("description").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI));
|
||||
QRecord record = new QRecord().withValue("id", 1).withValue("name", "Widget").withValue("description", "PHI-CANARY");
|
||||
record.setRecordLabel("Widget label");
|
||||
QuickSearchableTableConfig config = new QuickSearchableTableConfig().withTableName("product").withPrimaryKeyField("id")
|
||||
.withSearchableFields(List.of("name", "description"));
|
||||
|
||||
assertThat(IndexingUtils.buildDocument(record, config).getRecordLabel()).isEqualTo("Widget label");
|
||||
record.setRecordLabel(null);
|
||||
assertThat(IndexingUtils.buildDocument(record, config).getRecordLabel()).isEqualTo("Widget");
|
||||
assertThat(QuickSearchFieldRules.requiresFieldFiltering("product")).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both table and configured label formats must exclude protected fields.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testProtectedLabelFieldsOmitPreformattedAndComputedLabels()
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable("person");
|
||||
table.getField("email").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.withRecordLabelFields("name", "email").withRecordLabelFormat("%s %s");
|
||||
QRecord record = new QRecord().withValue("id", 1).withValue("name", "Public name").withValue("email", "PII-CANARY");
|
||||
record.setRecordLabel("Public name PII-CANARY");
|
||||
QuickSearchableTableConfig config = new QuickSearchableTableConfig().withTableName("person").withPrimaryKeyField("id")
|
||||
.withSearchableFields(List.of("name", "email"))
|
||||
.withRecordLabelFields(List.of("email")).withRecordLabelFormat("%s");
|
||||
|
||||
assertThat(IndexingUtils.buildDocument(record, config).getRecordLabel()).isNull();
|
||||
record.setRecordLabel(null);
|
||||
assertThat(IndexingUtils.buildDocument(record, config).getRecordLabel()).isNull();
|
||||
table.withRecordLabelFields("name").withRecordLabelFormat("%s");
|
||||
record.setRecordLabel("PII-CANARY");
|
||||
assertThat(IndexingUtils.buildDocument(record, config).getRecordLabel()).isEqualTo("Public name");
|
||||
assertThat(record.getRecordLabel()).isEqualTo("PII-CANARY");
|
||||
table.withRecordLabelFormat(null);
|
||||
assertThat(IndexingUtils.buildDocument(record, config).getRecordLabel()).isEqualTo("1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hidden keys and timestamps remain usable unless they are protected.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testHiddenPrimaryKeyAndTimestampAreNotProtected()
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable("product");
|
||||
table.getField("id").withIsHidden(true);
|
||||
table.getField("modifyDate").withIsHidden(true);
|
||||
Instant timestamp = Instant.parse("2026-10-14T12:00:00Z");
|
||||
QRecord record = new QRecord().withValue("id", 1).withValue("name", "Widget").withValue("modifyDate", timestamp);
|
||||
QuickSearchableTableConfig config = new QuickSearchableTableConfig().withTableName("product").withPrimaryKeyField("id")
|
||||
.withSearchableFields(List.of("name")).withBasepullTimestampField("modifyDate");
|
||||
|
||||
OpenSearchDocument document = IndexingUtils.buildDocument(record, config);
|
||||
assertThat(document.getRecordId()).isEqualTo("1");
|
||||
assertThat(document.getVersion()).isEqualTo(timestamp.toEpochMilli());
|
||||
assertThat(QuickSearchFieldRules.hasProtectedPrimaryKey("product")).isFalse();
|
||||
assertThat(QuickSearchFieldRules.requiresFieldFiltering("product")).isFalse();
|
||||
table.getField("modifyDate").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI));
|
||||
assertThat(IndexingUtils.buildDocument(record, config).getVersion()).isNull();
|
||||
table.getField("id").withIsSensitive(true);
|
||||
assertThat(QuickSearchFieldRules.hasProtectedPrimaryKey("product")).isTrue();
|
||||
assertThat(IndexingUtils.buildDocument(record, config)).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test that normal input is trimmed and its case is preserved (the analyzer
|
||||
** lower-cases at index and search time).
|
||||
@@ -606,7 +747,7 @@ class IndexingUtilsTest
|
||||
.withSearchableFields(List.of("sku"))
|
||||
.withFieldIncludeLabels(Map.of("sku", true));
|
||||
|
||||
assertThat(IndexingUtils.buildDocument(record, tableConfig).getSearchableText()).isEqualTo("sku: AB-1");
|
||||
assertThat(IndexingUtils.buildDocument(record, tableConfig).getSearchableText()).isEqualTo("Sku: AB-1");
|
||||
|
||||
initContextWithPersonTable();
|
||||
assertThat(IndexingUtils.buildDocument(record, tableConfig).getSearchableText()).isEqualTo("SKU Code: AB-1");
|
||||
|
||||
+13
-1
@@ -21,8 +21,13 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
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.qbits.quicksearch.BaseQuickSearchTest;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.BulkIndexResult;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument;
|
||||
@@ -46,7 +51,7 @@ import static org.mockito.Mockito.when;
|
||||
/*******************************************************************************
|
||||
** Tests for SynchronousIndexEventPublisher.
|
||||
*******************************************************************************/
|
||||
class SynchronousIndexEventPublisherTest
|
||||
class SynchronousIndexEventPublisherTest extends BaseQuickSearchTest
|
||||
{
|
||||
private QuickSearchOpenSearchClient mockClient;
|
||||
private SynchronousIndexEventPublisher publisher;
|
||||
@@ -61,6 +66,13 @@ class SynchronousIndexEventPublisherTest
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
QContext.getQInstance().addTable(
|
||||
new QTableMetaData().withName("orders").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("orderNumber", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("customerName", QFieldType.STRING)));
|
||||
QContext.getQInstance().addTable(new QTableMetaData().withName("products").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("name", QFieldType.STRING)));
|
||||
mockClient = mock(QuickSearchOpenSearchClient.class);
|
||||
|
||||
QuickSearchableTableConfig ordersConfig = new QuickSearchableTableConfig()
|
||||
|
||||
Reference in new issue
Block a user