Files
qbit-data-analysis/src/test/java/com/kingsrook/qbits/dataanalysis/FieldAnalysisAccessTest.java
T
james.maesandClaude Opus 5.5 73de290a93 feat(help): ship the qbit-data-analysis help bundle through QQQ help bundles, with coverage gate
Host-neutral help (deployment-specific facts removed), registered with
HelpBundles, gated by HelpBundleAssertions. QQQ 4.1.0-RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:27:13 -05:00

248 lines
14 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. 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.dataanalysis;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.dataanalysis.engine.CoreTierFieldAccessResolver;
import com.kingsrook.qbits.dataanalysis.engine.DrillFilters;
import com.kingsrook.qbits.dataanalysis.engine.FieldAnalysisAccess;
import com.kingsrook.qbits.dataanalysis.engine.FieldAnalysisAccessResolver;
import com.kingsrook.qbits.dataanalysis.engine.FieldSecurityLockFieldAccessResolver;
import com.kingsrook.qbits.dataanalysis.result.DistinctValuesRequest;
import com.kingsrook.qbits.dataanalysis.result.ValueCount;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfile;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
import com.kingsrook.qbits.dataanalysis.result.Metric;
import com.kingsrook.qbits.dataanalysis.result.PivotCell;
import com.kingsrook.qbits.dataanalysis.result.PivotDimension;
import com.kingsrook.qbits.dataanalysis.result.PivotMeasure;
import com.kingsrook.qbits.dataanalysis.result.PivotRequest;
import com.kingsrook.qbits.dataanalysis.result.PivotResult;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.within;
/*******************************************************************************
** Exercise reflection without shadowing a future real core package on test classpaths.
*******************************************************************************/
class FieldAnalysisAccessTest extends EngineFixture
{
/*******************************************************************************
** Test helper for field-access behavior.
*******************************************************************************/
public static class TierApi
{
static String tier = "FULL";
static boolean fail;
/*******************************************************************************
** Verify or support the pivot and field-access contract.
*******************************************************************************/
public static String getTier(QTableMetaData table, String field)
{
if(fail)
{
throw new IllegalStateException("unavailable");
}
return (tier);
}
/*******************************************************************************
** Verify or support the pivot and field-access contract.
*******************************************************************************/
public static String getAccessFingerprint()
{
if(fail)
{
throw new IllegalStateException("unavailable");
}
return ("policy-v1");
}
}
/*******************************************************************************
** Test helper for field-access behavior.
*******************************************************************************/
public static class TierOnlyApi
{
/*******************************************************************************
** Verify or support the pivot and field-access contract.
*******************************************************************************/
public static String getTier(QTableMetaData table, String field)
{
return ("FULL");
}
}
/*******************************************************************************
** Verify or support the pivot and field-access contract.
*******************************************************************************/
@Test
void legacyFallbackAndReflectiveTierMapping() throws Exception
{
var table = instance.getTable("orders");
var fallback = new FieldSecurityLockFieldAccessResolver();
assertThat(fallback.resolve(table, table.getField("amount"))).isEqualTo(FieldAnalysisAccess.FULL);
assertThat(fallback.resolve(table, table.getField("secret"))).isEqualTo(FieldAnalysisAccess.NONE);
// RC.7 supplies the core tier API; verify discovery and effective access.
var defaultResolver = CoreTierFieldAccessResolver.defaultResolver();
assertThat(defaultResolver).isInstanceOf(CoreTierFieldAccessResolver.class);
assertThat(defaultResolver.resolve(table, table.getField("amount"))).isEqualTo(FieldAnalysisAccess.FULL);
assertThat(defaultResolver.resolve(table, table.getField("secret"))).isEqualTo(FieldAnalysisAccess.NONE);
var resolver = new CoreTierFieldAccessResolver(TierApi.class);
try
{
for(String tier : List.of("FULL", "REVEALABLE", "MASKED", "HIDDEN", "UNKNOWN"))
{
TierApi.tier = tier;
assertThat(resolver.resolve(table, table.getField("amount"))).isEqualTo(tier.equals("FULL") ? FieldAnalysisAccess.FULL : tier.equals("REVEALABLE") ? FieldAnalysisAccess.EXACT_MATCH_ONLY : FieldAnalysisAccess.NONE);
}
assertThat(resolver.getAccessFingerprint()).isEqualTo("policy-v1");
TierApi.fail = true;
assertThat(resolver.resolve(table, table.getField("amount"))).isEqualTo(FieldAnalysisAccess.NONE);
assertThatThrownBy(resolver::getAccessFingerprint).isInstanceOf(IllegalStateException.class);
assertThat(new CoreTierFieldAccessResolver(TierOnlyApi.class).getAccessFingerprint()).isEmpty();
}
finally
{
TierApi.fail = false;
TierApi.tier = "FULL";
}
}
/*******************************************************************************
** Verify or support the pivot and field-access contract.
*******************************************************************************/
@Test
void customPolicyAndUserFilterPassThrough() throws Exception
{
config.withFieldAnalysisAccessResolver(() -> (table, field) -> field.getName().equals("note") ? FieldAnalysisAccess.NONE
: field.getName().equals("paid") ? FieldAnalysisAccess.EXACT_MATCH_ONLY : new FieldSecurityLockFieldAccessResolver().resolve(table, field));
service = new DataAnalysisService(config, Runnable::run);
assertThat(service.capabilities("orders").getFields()).doesNotContain("note", "paid", "secret");
for(String field : List.of("note", "paid"))
{
assertThatThrownBy(() -> service.columnProfile(new ColumnProfileRequest().withTableName("orders").withFieldName(field)))
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QBadRequestException.class);
assertThatThrownBy(() -> service.pivot(new PivotRequest().withTableName("orders").withRows(List.of(new PivotDimension().withField(field)))
.withValues(List.of(new PivotMeasure().withOperator(PivotMeasure.Operator.COUNT)))))
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QBadRequestException.class);
assertThatThrownBy(() -> service.pivot(new PivotRequest().withTableName("orders").withRows(List.of(new PivotDimension().withField("status")))
.withValues(List.of(new PivotMeasure().withOperator(PivotMeasure.Operator.MIN).withField(field)))))
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QBadRequestException.class);
}
var request = new PivotRequest().withTableName("orders").withRows(List.of(new PivotDimension().withField("status")))
.withValues(List.of(new PivotMeasure().withOperator(PivotMeasure.Operator.COUNT)));
assertThat(JsonUtils.toJson(service.pivot(request))).doesNotContain("\"fieldName\":\"note\"", "\"fieldName\":\"paid\"");
var ownFilter = DrillFilters.condition("paid", QCriteriaOperator.EQUALS, true);
String before = JsonUtils.toJson(ownFilter);
PivotResult result = service.pivot(request.withFilter(ownFilter));
assertThat(JsonUtils.toJson(ownFilter)).isEqualTo(before);
assertThat(((Number)result.getGrandTotals().getFirst().getValue()).intValue()).isEqualTo(6);
for(PivotCell cell : result.getCells())
{
var drill = cell.getValues().getFirst().getDrill().getFilter();
assertThat(JsonUtils.toJson(drill.getSubFilters().getFirst())).isEqualTo(before);
assertThat(JsonUtils.toJson(drill.getSubFilters().get(1))).doesNotContain("paid", "note");
}
}
/*******************************************************************************
** Verify or support the pivot and field-access contract.
*******************************************************************************/
@Test
void cacheMissesOnFieldTierAndCoreFingerprintChanges() throws Exception
{
AtomicReference<FieldAnalysisAccess> tier = new AtomicReference<>(FieldAnalysisAccess.FULL);
AtomicReference<String> fingerprint = new AtomicReference<>("first");
config.withFieldAnalysisAccessResolver(() -> new FieldAnalysisAccessResolver()
{
/*******************************************************************************
** Verify or support the pivot and field-access contract.
*******************************************************************************/
@Override
public FieldAnalysisAccess resolve(QTableMetaData table, QFieldMetaData field)
{
return (field.getName().equals("quantity") ? tier.get() : new FieldSecurityLockFieldAccessResolver().resolve(table, field));
}
/*******************************************************************************
** Verify or support the pivot and field-access contract.
*******************************************************************************/
@Override
public String getAccessFingerprint()
{
return (fingerprint.get());
}
});
service = new DataAnalysisService(config, Runnable::run);
ColumnProfileRequest request = new ColumnProfileRequest().withTableName("orders").withFieldName("amount").withExact(true);
String first = service.startExactRun(request);
assertThat(service.startExactRun(request)).isEqualTo(first);
tier.set(FieldAnalysisAccess.EXACT_MATCH_ONLY);
String second = service.startExactRun(request);
assertThat(second).isNotEqualTo(first);
assertThatThrownBy(() -> service.getRun(first)).isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
fingerprint.set("second");
assertThat(service.startExactRun(request)).isNotEqualTo(second);
}
/*******************************************************************************
** Verify or support the pivot and field-access contract.
*******************************************************************************/
@Test
void generatedRepresentativeDrillsCannotUseRestrictedPrimaryKey() throws Exception
{
config.withFieldAnalysisAccessResolver(() -> (table, field) -> field.getName().equals("id") ? FieldAnalysisAccess.NONE
: new FieldSecurityLockFieldAccessResolver().resolve(table, field));
service = new DataAnalysisService(config, Runnable::run);
ColumnProfile profile = service.columnProfile(new ColumnProfileRequest().withTableName("orders").withFieldName("status").withExact(true));
Metric distinct = profile.getCounts().stream().filter(m -> m.getKey().equals("distinct")).findFirst().orElseThrow();
assertThat(distinct.getDrill()).isNull();
assertThat(profile.getCounts().getFirst().getDrill()).isNotNull();
var ownFilter = DrillFilters.condition("id", QCriteriaOperator.EQUALS, 1);
profile = service.columnProfile(new ColumnProfileRequest().withTableName("orders").withFieldName("status").withFilter(ownFilter).withExact(true));
assertThat(profile.getCounts().getFirst().getDrill()).isNotNull();
assertThat(JsonUtils.toJson(profile.getCounts().getFirst().getDrill())).contains("id");
}
/*******************************************************************************
** Related label tiers must neither leak labels nor reuse a stale cache entry.
*******************************************************************************/
@Test
void relatedLabelTierChangesInvalidateCache() throws Exception
{
AtomicReference<FieldAnalysisAccess> tier = new AtomicReference<>(FieldAnalysisAccess.FULL);
config.withFieldAnalysisAccessResolver(() -> (table, field) -> table.getName().equals("customer") && field.getName().equals("name")
? tier.get() : new FieldSecurityLockFieldAccessResolver().resolve(table, field));
service = new DataAnalysisService(config, Runnable::run);
ColumnProfileRequest request = new ColumnProfileRequest().withTableName("orders").withFieldName("customer").withExact(true);
String first = service.startExactRun(request);
assertThat(service.columnProfile(request).getTopValues()).extracting(ValueCount::getLabel).contains("Alice Co");
tier.set(FieldAnalysisAccess.NONE);
assertThat(service.startExactRun(request)).isNotEqualTo(first);
assertThat(service.columnProfile(request).getTopValues()).extracting(ValueCount::getLabel).containsExactly("1", "2");
assertThat(service.distinctValues(new DistinctValuesRequest().withTableName("orders").withFieldName("customer")).getValues()).extracting(ValueCount::getLabel).containsExactly("1", "2");
}
}