Files
qbit-data-analysis/src/main/java/com/kingsrook/qbits/dataanalysis/api/DataAnalysisApi.java
T
james.maesandClaude Opus 5.5 d2c9871d98 feat(saved): saved, shared, starred, scheduled and widget analyses, export, SQL handoff
Extension 1.4.0: saved analyses of every kind re-run as the viewer
(fields they cannot analyze are dropped), owner-or-shared READ through
core record locks with owner-only writes (and a database CHECK so a
private row can never lose its read owner), personal stars, schedules
that run as the owner and mail only recipients who could run the analysis
themselves, CSV/XLSX export, the SQL Workbench handoff, an analysis
dashboard widget, and an Analyses home built from standard widgets.

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

795 lines
43 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.api;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
import com.kingsrook.qbits.dataanalysis.DataAnalysisService;
import com.kingsrook.qbits.dataanalysis.engine.CoreAnalysisBackend;
import com.kingsrook.qbits.dataanalysis.result.AnalysisRequest;
import com.kingsrook.qbits.dataanalysis.result.ChartKind;
import com.kingsrook.qbits.dataanalysis.result.ColumnProfileRequest;
import com.kingsrook.qbits.dataanalysis.result.CompareRequest;
import com.kingsrook.qbits.dataanalysis.result.CorrelationRequest;
import com.kingsrook.qbits.dataanalysis.result.CrosstabRequest;
import com.kingsrook.qbits.dataanalysis.result.DistinctValuesRequest;
import com.kingsrook.qbits.dataanalysis.result.DuplicatesRequest;
import com.kingsrook.qbits.dataanalysis.result.PivotRequest;
import com.kingsrook.qbits.dataanalysis.result.ScatterRequest;
import com.kingsrook.qbits.dataanalysis.result.ShiftRequest;
import com.kingsrook.qbits.dataanalysis.result.TableProfileRequest;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
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.utils.JsonUtils;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
import com.kingsrook.qqq.openapi.model.Schema;
import com.kingsrook.qqq.openapi.model.Type;
/*******************************************************************************
** Session-bound analysis operations registered only with optional Javalin present.
*******************************************************************************/
public class DataAnalysisApi
{
public static final String PREFIX = "/qbit/data-analysis";
/*******************************************************************************
** Publish the extension through the core spec registry.
*******************************************************************************/
public static void register(QInstance instance)
{
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
}
/*******************************************************************************
** The same descriptor drives routes, OpenAPI and assistant tool discovery.
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("data-analysis").withSemanticVersion("1.4.0")
.withDescription("Profiles, pivots, comparisons, distribution shifts, correlations, scatter samples, crosstabs and anomaly watch previews as the signed-in user.")
.withSpec(SavedApi.Run.class).withSpec(SavedApi.ListSaved.class).withSpec(SavedApi.Star.class).withSpec(SavedApi.Unstar.class)
.withSpec(SavedApi.Sql.class).withSpec(AnalysisFileSpec.class).withSpec(AnalysisFileSpec.Saved.class)
.withSpec(CapabilitiesSpec.class).withSpec(ColumnProfileSpec.class).withSpec(DistinctValuesSpec.class)
.withSpec(CompareSpec.class).withSpec(ShiftSpec.class).withSpec(CorrelationSpec.class).withSpec(ScatterSpec.class).withSpec(CrosstabSpec.class)
.withSpec(WatchPreviewSpec.class).withSpec(PivotSpec.class).withSpec(TableProfileSpec.class).withSpec(DuplicatesSpec.class).withSpec(StartRunSpec.class).withSpec(GetRunSpec.class));
}
/*******************************************************************************
** Common schema catalog and path parameters for the analysis JSON specs.
*******************************************************************************/
public abstract static class ProfileSpec extends AbstractJsonEndpointSpec
{
private final String path;
private final HttpMethod method;
private final String summary;
private final String description;
private final String request;
private final String response;
/*******************************************************************************
** Bind immutable operation metadata.
*******************************************************************************/
protected ProfileSpec(String path, HttpMethod method, String summary, String description, String request, String response)
{
this.path = path;
this.method = method;
this.summary = summary;
this.description = description;
this.request = request;
this.response = response;
}
/*******************************************************************************
** Assistant-facing operation purpose.
*******************************************************************************/
@Override
public BasicOperation defineBasicOperation()
{
return (new BasicOperation().withPath(PREFIX + path).withHttpMethod(method).withShortSummary(summary).withLongDescription(description));
}
/*******************************************************************************
** Required path parameter, when present.
*******************************************************************************/
@Override
public List<Parameter> defineRequestParameters()
{
String name = path.contains("{table}") ? "table" : path.contains("{runId}") ? "runId" : path.contains("{id}") ? "id" : null;
return (name == null ? List.of() : List.of(new Parameter().withName(name).withIn(In.PATH).withRequired(true)
.withDescription(name.equals("table") ? "QQQ table name to analyze." : "Owner-scoped analysis run or watch id.").withSchema(new Schema().withType(Type.STRING))));
}
/*******************************************************************************
** Versioned request schema.
*******************************************************************************/
@Override
protected Schema defineRequestBodySchema()
{
return (request == null ? null : new Schema().withRef("#/components/schemas/DataAnalysis" + request + "V1"));
}
/*******************************************************************************
** Versioned response schema.
*******************************************************************************/
@Override
protected String getResponseSchemaName()
{
return ("DataAnalysis" + response + "V1");
}
/*******************************************************************************
** Include all referenced transport models and the shared core filter.
*******************************************************************************/
@Override
public Map<String, Schema> defineComponentSchemas()
{
return (this instanceof CapabilitiesSpec ? DataAnalysisSchemas.components() : Map.of());
}
}
/*******************************************************************************
** Existing table lookup before invoking permission-sensitive operations.
*******************************************************************************/
private static QTableMetaData table(String name) throws QNotFoundException
{
QTableMetaData table = QContext.getQInstance().getTable(name);
if(table == null)
{
throw new QNotFoundException("Unknown analysis table.");
}
return (table);
}
/*******************************************************************************
** Parse one JSON object with strict scalar/list types and core filter JSON.
*******************************************************************************/
private static AnalysisRequest request(String operation, String table, Map<String, Object> body) throws Exception
{
table(table);
DataAnalysisService service = DataAnalysisRuntime.service();
List<String> readable = service.capabilities(table).getFields();
try
{
for(String name : List.of("fieldName", "search"))
{
if(body.get(name) != null && !(body.get(name) instanceof String))
{
throw new IllegalArgumentException(name + " must be a string.");
}
}
if(body.get("exact") != null && !(body.get("exact") instanceof Boolean))
{
throw new IllegalArgumentException("exact must be a boolean.");
}
QQueryFilter filter = body.get("filter") == null ? null : JsonUtils.toObject(JsonUtils.toJson(body.get("filter")), QQueryFilter.class);
validateFilter(filter);
List<String> fields = null;
if(body.get("fields") != null)
{
if(!(body.get("fields") instanceof List<?> values) || values.stream().anyMatch(v -> !(v instanceof String)))
{
throw new IllegalArgumentException("fields must be an array of field names.");
}
fields = ((List<?>) body.get("fields")).stream().map(String.class::cast).toList();
if(fields.isEmpty() || !readable.containsAll(fields))
{
throw new IllegalArgumentException("Choose readable analysis fields.");
}
}
String field = (String) body.get("fieldName");
if((operation.equals("COLUMN_PROFILE") || operation.equals("DISTINCT_VALUES")) && !readable.contains(field))
{
throw new IllegalArgumentException("Choose a readable analysis field.");
}
boolean exact = Boolean.TRUE.equals(body.get("exact"));
return (switch(operation)
{
case "COLUMN_PROFILE" -> new ColumnProfileRequest().withTableName(table).withFilter(filter).withFieldName(field).withExact(exact);
case "TABLE_PROFILE" -> new TableProfileRequest().withTableName(table).withFilter(filter).withFields(fields);
case "DISTINCT_VALUES" -> new DistinctValuesRequest().withTableName(table).withFilter(filter).withFieldName(field)
.withPage(integer(body, "page", 0, 0, 1_000_000)).withPageSize(integer(body, "pageSize", 100, 1, 500)).withSearch((String) body.get("search"));
case "DUPLICATES" -> new DuplicatesRequest().withTableName(table).withFilter(filter)
.withFields(java.util.Objects.requireNonNull(fields, "Choose duplicate fields.")).withLimit(integer(body, "limit", 50, 1, 500));
default -> throw new IllegalArgumentException("operation must be COLUMN_PROFILE or TABLE_PROFILE.");
});
}
catch(Exception e)
{
throw new QBadRequestException("Invalid analysis request: " + e.getMessage());
}
}
/*******************************************************************************
** Bad filter fields fail before any aggregate or cached result is consulted.
*******************************************************************************/
private static void validateFilter(QQueryFilter filter)
{
if(filter == null)
{
return;
}
for(var criterion : filter.getCriteria())
{
if(criterion.getOperator() == null || criterion.getFieldName() == null)
{
throw new IllegalArgumentException("Choose readable filter fields and a valid operator.");
}
}
filter.getSubFilters().forEach(sub -> validateFilter(sub));
}
/*******************************************************************************
** Reject fractional, null, overflowing and out-of-range numeric parameters.
*******************************************************************************/
private static int integer(Map<String, Object> body, String name, int fallback, int min, int max)
{
if(!body.containsKey(name))
{
return (fallback);
}
Object value = body.get(name);
if(!(value instanceof Number number) || number.doubleValue() != number.intValue() || number.intValue() < min || number.intValue() > max)
{
throw new IllegalArgumentException(name + " must be an integer from " + min + " to " + max + ".");
}
return (number.intValue());
}
/*******************************************************************************
** Transport conversion avoids middleware dependencies in engine results.
*******************************************************************************/
private static <T> T transport(Object value, Class<T> type) throws Exception
{
return (JsonUtils.toObject(JsonUtils.toJson(value), type));
}
/*******************************************************************************
** Check analysis availability.
*******************************************************************************/
public static class CapabilitiesSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public CapabilitiesSpec()
{
super("/tables/{table}/capabilities", HttpMethod.GET, "Check analysis availability",
"Discover whether the current user can analyze this table, readable fields, chart defaults, limits and backend capabilities.", null, "Capabilities");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (capabilities(input.getPathParam("table")));
}
}
/*******************************************************************************
** Profile one column.
*******************************************************************************/
public static class ColumnProfileSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public ColumnProfileSpec()
{
super("/tables/{table}/column-profile", HttpMethod.POST, "Profile one column",
"Profile one column of a table under a filter: counts, nulls, distinct values, top values, numeric statistics and histogram, date patterns; every number has a drill-through filter unless privacy suppresses it.", "ColumnProfileRequest", "ColumnProfile");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (transport(DataAnalysisRuntime.service().columnProfile((ColumnProfileRequest) request("COLUMN_PROFILE", input.getPathParam("table"), input.getBody())), DataAnalysisSchemas.DataAnalysisColumnProfileV1.class));
}
}
/*******************************************************************************
** List distinct column values.
*******************************************************************************/
public static class DistinctValuesSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public DistinctValuesSpec()
{
super("/tables/{table}/distinct-values", HttpMethod.POST, "List distinct column values",
"Page through distinct stored values with display labels, counts and drill-through filters; search narrows stored values.", "DistinctValuesRequest", "DistinctValuesPage");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (transport(DataAnalysisRuntime.service().distinctValues((DistinctValuesRequest) request("DISTINCT_VALUES", input.getPathParam("table"), input.getBody())), DataAnalysisSchemas.DataAnalysisDistinctValuesPageV1.class));
}
}
/*******************************************************************************
** Profile table completeness and keys.
*******************************************************************************/
public static class TableProfileSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public TableProfileSpec()
{
super("/tables/{table}/table-profile", HttpMethod.POST, "Profile table completeness and keys",
"Profile readable fields under a filter for completeness, uniqueness, constant or empty columns and candidate keys.", "TableProfileRequest", "TableProfile");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (transport(DataAnalysisRuntime.service().tableProfile((TableProfileRequest) request("TABLE_PROFILE", input.getPathParam("table"), input.getBody())), DataAnalysisSchemas.DataAnalysisTableProfileV1.class));
}
}
/*******************************************************************************
** Find duplicate groups.
*******************************************************************************/
public static class DuplicatesSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public DuplicatesSpec()
{
super("/tables/{table}/duplicates", HttpMethod.POST, "Find duplicate groups",
"Group matching records by the selected readable fields and return repeated tuples with counts and drill-through filters.", "DuplicatesRequest", "DuplicatesResult");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (transport(DataAnalysisRuntime.service().duplicates((DuplicatesRequest) request("DUPLICATES", input.getPathParam("table"), input.getBody())), DataAnalysisSchemas.DataAnalysisDuplicatesResultV1.class));
}
}
/*******************************************************************************
** Start an exact profile run.
*******************************************************************************/
public static class StartRunSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public StartRunSpec()
{
super("/runs", HttpMethod.POST, "Start an exact profile run",
"Start an owner-scoped exact column or table profile. Poll the returned runId until COMPLETE or FAILED; results honor the current session permissions.", "StartRunRequest", "RunStarted");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
Map<String, Object> body = input.getBody();
if(body == null || !(body.get("tableName") instanceof String name) || !(body.get("operation") instanceof String operation)
|| !List.of("COLUMN_PROFILE", "TABLE_PROFILE").contains(operation) || !(body.get("request") instanceof Map<?, ?>))
{
throw new QBadRequestException("Supply operation, tableName and a profile request object.");
}
Map<String, Object> parameters = JsonUtils.toObject(JsonUtils.toJson(body.get("request")), java.util.LinkedHashMap.class);
String id = DataAnalysisRuntime.service().startExactRun(request(operation, name, parameters));
DataAnalysisSchemas.DataAnalysisRunStartedV1 result = new DataAnalysisSchemas.DataAnalysisRunStartedV1();
result.runId = id;
result.status = DataAnalysisRuntime.service().getRun(id).getStatus();
return (result);
}
}
/*******************************************************************************
** Get an exact profile run.
*******************************************************************************/
public static class GetRunSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor used by the core extension registry.
*******************************************************************************/
public GetRunSpec()
{
super("/runs/{runId}", HttpMethod.GET, "Get an exact profile run",
"Read status, timestamps and the typed columnProfile or tableProfile result of an owner-scoped run. Completed results are rechecked against current permissions.", null, "RunStatus");
}
/*******************************************************************************
** Execute in the request session established by core middleware.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
return (transport(DataAnalysisRuntime.service().getRun(input.getPathParam("runId")), DataAnalysisSchemas.DataAnalysisRunStatusV1.class));
}
}
/*******************************************************************************
** Permission probing is a successful response with no disclosed field list.
*******************************************************************************/
private static DataAnalysisSchemas.DataAnalysisCapabilitiesV1 capabilities(String name) throws Exception
{
QTableMetaData table = table(name);
DataAnalysisSchemas.DataAnalysisCapabilitiesV1 result = new DataAnalysisSchemas.DataAnalysisCapabilitiesV1();
result.tableName = name;
result.tableLabel = table.getLabel();
result.canAnalyze = false;
result.fields = List.of();
result.backendCapabilities = List.of();
result.sqlWorkbenchAvailable = com.kingsrook.qbits.dataanalysis.saved.SqlHandoff.installedAndAllowed();
try
{
var capabilities = DataAnalysisRuntime.service().capabilities(name);
result.canAnalyze = true;
result.backendCapabilities = CoreAnalysisBackend.capabilities(name).stream().map(Enum::name).sorted().toList();
result.fields = capabilities.getFields().stream().map(f -> capabilityField(table.getField(f))).toList();
var config = capabilities.getConfig();
result.limits = new DataAnalysisSchemas.DataAnalysisLimitsV1();
result.limits.maxRows = config.getMaxRows();
result.limits.sampleThresholdRows = config.getSampleThresholdRows();
result.limits.sampleRows = config.getSampleRows();
result.limits.minGroupSize = config.getMinGroupSize();
}
catch(QPermissionDeniedException expected)
{
// A UI capability probe deliberately reports denial without an error.
}
return (result);
}
/*******************************************************************************
** Semantic type and the default visualization, with PVS/FK taking priority.
*******************************************************************************/
private static DataAnalysisSchemas.DataAnalysisCapabilityFieldV1 capabilityField(QFieldMetaData field)
{
DataAnalysisSchemas.DataAnalysisCapabilityFieldV1 result = new DataAnalysisSchemas.DataAnalysisCapabilityFieldV1();
result.name = field.getName();
result.label = field.getLabel();
result.type = field.getType().name();
result.kind = field.getPossibleValueSourceName() != null ? "CATEGORY" : switch(field.getType())
{
case INTEGER, LONG, DECIMAL -> "NUMERIC";
case DATE -> "DATE";
case DATE_TIME -> "DATE_TIME";
case BOOLEAN -> "BOOLEAN";
default -> "TEXT";
};
result.defaultChartKind = switch(result.kind)
{
case "NUMERIC" -> ChartKind.HISTOGRAM;
case "DATE", "DATE_TIME" -> ChartKind.TIME_SERIES;
default -> ChartKind.BAR;
};
return (result);
}
/*******************************************************************************
** Group records by row and column dimensions.
*******************************************************************************/
public static class PivotSpec extends ProfileSpec
{
/*******************************************************************************
** Publish assistant-friendly grouping and measure capabilities.
*******************************************************************************/
public PivotSpec()
{
super("/tables/{table}/pivot", HttpMethod.POST, "Explore a pivot table",
"Group records by up to 3 row and 2 column fields (with date or numeric buckets) and compute counts, sums, averages, min/max, distinct counts or percentiles per cell; every cell has a drill-through filter.", "PivotRequest", "Pivot");
}
/*******************************************************************************
** Strict body validation precedes the session-bound engine.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
table(tableName);
DataAnalysisRuntime.service().capabilities(tableName);
PivotRequest pivot;
try
{
Map<String, Object> body = input.getBody();
integer(body, "limitGroups", 50, 1, 200);
if(body.containsKey("showTotals") && !(body.get("showTotals") instanceof Boolean))
{
throw new IllegalArgumentException("showTotals must be a boolean.");
}
for(String axis : List.of("rows", "columns"))
{
if(body.containsKey(axis))
{
if(!(body.get(axis) instanceof List<?> dimensions))
{
throw new IllegalArgumentException("Dimensions must be arrays.");
}
for(Object dimension : dimensions)
{
if(!(dimension instanceof Map<?, ?> map) || !(map.get("field") instanceof String))
{
throw new IllegalArgumentException("Each dimension requires a field name.");
}
if(map.get("numericBins") instanceof Map<?, ?> bins)
{
Object count = bins.get("count");
if(count != null && (!(count instanceof Number number) || number.doubleValue() != number.intValue()))
{
throw new IllegalArgumentException("Bin count must be an integer.");
}
}
}
}
}
if(!(body.get("values") instanceof List<?> values) || values.stream().anyMatch(v -> !(v instanceof Map<?, ?> m)
|| !(m.get("operator") instanceof String) || m.get("field") != null && !(m.get("field") instanceof String)))
{
throw new IllegalArgumentException("Measures require an operator and optional field name.");
}
pivot = JsonUtils.toObject(JsonUtils.toJson(body), PivotRequest.class).withTableName(tableName);
validateFilter(pivot.getFilter());
}
catch(Exception e)
{
throw new QBadRequestException("Invalid pivot request.");
}
return (transport(DataAnalysisRuntime.service().pivot(pivot), DataAnalysisSchemas.DataAnalysisPivotV1.class));
}
}
/*******************************************************************************
** Strict JSON types before Jackson conversion; engine validates FULL fields.
*******************************************************************************/
private static <T> T relationshipRequest(String tableName, Map<String, Object> body, Class<T> type) throws Exception
{
table(tableName);
DataAnalysisRuntime.service().capabilities(tableName);
try
{
for(String name : List.of("field", "dateField", "bucket", "xField", "yField", "rowField", "columnField", "labelA", "labelB"))
{
if(body.containsKey(name) && !(body.get(name) instanceof String))
{
throw new IllegalArgumentException("Expected a field name or label.");
}
}
if(body.containsKey("fields") && (!(body.get("fields") instanceof List<?> fields) || fields.stream().anyMatch(f -> !(f instanceof String))))
{
throw new IllegalArgumentException("Expected an array of field names.");
}
if(body.containsKey("limit"))
{
integer(body, "limit", 2000, 1, type == CrosstabRequest.class ? 20 : 2000);
}
for(String name : List.of("filter", "filterA", "filterB"))
{
Object filter = body.get(name);
if(filter != null)
{
if(!(filter instanceof Map<?, ?>))
{
throw new IllegalArgumentException("Expected a query filter object.");
}
validateFilter(JsonUtils.toObject(JsonUtils.toJson(filter), QQueryFilter.class));
}
}
return (JsonUtils.toObject(JsonUtils.toJson(body), type));
}
catch(Exception e)
{
throw new QBadRequestException("Invalid relationship request.");
}
}
/*******************************************************************************
** Compare filtered A with filtered B (null means the whole permitted table). Return means, medians or category shares, B-minus-A deltas, valid significance tests and count-equivalent drills. Only FULL fields are accepted.
*******************************************************************************/
public static class CompareSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor for endpoint and assistant discovery.
*******************************************************************************/
public CompareSpec()
{
super("/tables/{table}/compare", HttpMethod.POST, "Compare two populations",
"Compare filtered A with filtered B (null means the whole permitted table). Return means, medians or category shares, B-minus-A deltas, valid significance tests and count-equivalent drills. Only FULL fields are accepted.", "CompareRequest", "Compare");
}
/*******************************************************************************
** Execute with the calling session and versioned transport contract.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
CompareRequest request = relationshipRequest(tableName, input.getBody(), CompareRequest.class).withTableName(tableName);
return (transport(DataAnalysisRuntime.service().compare(request), RelationshipSchemas.DataAnalysisCompareV1.class));
}
}
/*******************************************************************************
** Compare category shares or numeric quartiles across session-timezone calendar buckets. Compute descriptive PSI between the first and last complete nonempty buckets using fixed categories or baseline quartile bins.
*******************************************************************************/
public static class ShiftSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor for endpoint and assistant discovery.
*******************************************************************************/
public ShiftSpec()
{
super("/tables/{table}/shift", HttpMethod.POST, "Measure distribution shift over time",
"Compare category shares or numeric quartiles across session-timezone calendar buckets. Compute descriptive PSI between the first and last complete nonempty buckets using fixed categories or baseline quartile bins.", "ShiftRequest", "Shift");
}
/*******************************************************************************
** Execute with the calling session and versioned transport contract.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
ShiftRequest request = relationshipRequest(tableName, input.getBody(), ShiftRequest.class).withTableName(tableName);
return (transport(DataAnalysisRuntime.service().shift(request), RelationshipSchemas.DataAnalysisShiftV1.class));
}
}
/*******************************************************************************
** Compute a Pearson correlation matrix for 2 to 12 distinct FULL numeric fields, using paired non-null records. Every cell includes n and parameters for the scatter endpoint; core pushes down CORR or uses bounded streaming.
*******************************************************************************/
public static class CorrelationSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor for endpoint and assistant discovery.
*******************************************************************************/
public CorrelationSpec()
{
super("/tables/{table}/correlation", HttpMethod.POST, "Find numeric relationships",
"Compute a Pearson correlation matrix for 2 to 12 distinct FULL numeric fields, using paired non-null records. Every cell includes n and parameters for the scatter endpoint; core pushes down CORR or uses bounded streaming.", "CorrelationRequest", "Correlation");
}
/*******************************************************************************
** Execute with the calling session and versioned transport contract.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
CorrelationRequest request = relationshipRequest(tableName, input.getBody(), CorrelationRequest.class).withTableName(tableName);
return (transport(DataAnalysisRuntime.service().correlation(request), RelationshipSchemas.DataAnalysisCorrelationV1.class));
}
}
/*******************************************************************************
** Return at most 2000 paired numeric points as the user, ordered by primary key when core query sampling is unavailable, plus a least-squares line fitted to the sample. Privacy disables individual drills and suppresses small populations.
*******************************************************************************/
public static class ScatterSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor for endpoint and assistant discovery.
*******************************************************************************/
public ScatterSpec()
{
super("/tables/{table}/scatter", HttpMethod.POST, "Inspect a numeric relationship",
"Return at most 2000 paired numeric points as the user, ordered by primary key when core query sampling is unavailable, plus a least-squares line fitted to the sample. Privacy disables individual drills and suppresses small populations.", "ScatterRequest", "Scatter");
}
/*******************************************************************************
** Execute with the calling session and versioned transport contract.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
ScatterRequest request = relationshipRequest(tableName, input.getBody(), ScatterRequest.class).withTableName(tableName);
return (transport(DataAnalysisRuntime.service().scatter(request), RelationshipSchemas.DataAnalysisScatterV1.class));
}
}
/*******************************************************************************
** Count combinations of two FULL fields with row and column shares, up to 20 categories per axis plus Other. Test independence using chi-square and Cramer V after pooling low expected counts; privacy suppresses small cells and their drills.
*******************************************************************************/
public static class CrosstabSpec extends ProfileSpec
{
/*******************************************************************************
** Public constructor for endpoint and assistant discovery.
*******************************************************************************/
public CrosstabSpec()
{
super("/tables/{table}/crosstab", HttpMethod.POST, "Cross-tabulate two fields",
"Count combinations of two FULL fields with row and column shares, up to 20 categories per axis plus Other. Test independence using chi-square and Cramer V after pooling low expected counts; privacy suppresses small cells and their drills.", "CrosstabRequest", "Crosstab");
}
/*******************************************************************************
** Execute with the calling session and versioned transport contract.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
String tableName = input.getPathParam("table");
CrosstabRequest request = relationshipRequest(tableName, input.getBody(), CrosstabRequest.class).withTableName(tableName);
return (transport(DataAnalysisRuntime.service().crosstab(request), RelationshipSchemas.DataAnalysisCrosstabV1.class));
}
}
/*******************************************************************************
** Read-only watch preview; standard table endpoints own watch CRUD.
*******************************************************************************/
public static class WatchPreviewSpec extends ProfileSpec
{
/*******************************************************************************
** Publish a side-effect-free detector preview for a saved watch.
*******************************************************************************/
public WatchPreviewSpec()
{
super("/watches/{id}/preview", HttpMethod.POST, "Preview an anomaly watch",
"Compute complete time buckets and a detector verdict under the signed-in user's READ, Analyze and FULL-field permissions. Includes a baseline band annotation. Does not update the watch or raise alerts.", null, "WatchPreview");
}
/*******************************************************************************
** Missing or inaccessible watch ids return the same not-found response.
*******************************************************************************/
@Override
protected Object handle(JsonInput input) throws Exception
{
Integer id;
try
{
id = Integer.valueOf(input.getPathParam("id"));
}
catch(NumberFormatException e)
{
throw new QBadRequestException("Watch id must be an integer.");
}
return (transport(DataAnalysisRuntime.watches().preview(id, java.time.Instant.now()), WatchSchemas.DataAnalysisWatchPreviewV1.class));
}
}
}