Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe91118908 | ||
|
|
897996401a | ||
|
|
3233a885f7 | ||
|
|
468c7c639c |
No files matched your search
@@ -48,6 +48,13 @@ import com.kingsrook.qbits.help.HelpQBitConfig;
|
||||
import com.kingsrook.qbits.help.HelpQBitProducer;
|
||||
import com.kingsrook.qbits.jobs.JobsQBitConfig;
|
||||
import com.kingsrook.qbits.jobs.JobsQBitProducer;
|
||||
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitConfig;
|
||||
import com.kingsrook.qbits.objectstorage.ObjectStorageQBitProducer;
|
||||
import com.kingsrook.qbits.objectstorage.config.AddressingStyle;
|
||||
import com.kingsrook.qbits.objectstorage.config.BucketRole;
|
||||
import com.kingsrook.qbits.objectstorage.config.ProviderType;
|
||||
import com.kingsrook.qbits.objectstorage.config.StorageBucketConfig;
|
||||
import com.kingsrook.qbits.objectstorage.config.StorageConnectionConfig;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureQBitConfig;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureQBitProducer;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
@@ -76,7 +83,16 @@ import com.kingsrook.qqq.backend.core.health.HealthMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.columnstats.ColumnStatsStep;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
@@ -246,6 +262,24 @@ final class EnterpriseFeatures
|
||||
/////////////////////////
|
||||
// qbits //
|
||||
/////////////////////////
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// object storage on a local MinIO (enterprise-server.sh starts it): //
|
||||
// collaboration attachments live in its private "collaboration" //
|
||||
// bucket. Credentials are references to the server's env only. //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
String s3Endpoint = System.getenv("S3_ENDPOINT");
|
||||
if(s3Endpoint != null && !s3Endpoint.isBlank())
|
||||
{
|
||||
features.put("objectStorage", i -> new ObjectStorageQBitProducer().withQBitConfig(new ObjectStorageQBitConfig()
|
||||
.withBackendName(backendName).withSchedulerName(schedulerName).withAdminApp(true)
|
||||
.withConnection(new StorageConnectionConfig("local").withProviderType(ProviderType.MINIO)
|
||||
.withEndpoint("${env.S3_ENDPOINT}").withRegion("us-east-1").withAddressingStyle(AddressingStyle.PATH)
|
||||
.withAccessKeyRef("${env.S3_ACCESS_KEY}").withSecretKeyRef("${env.S3_SECRET_KEY}"))
|
||||
.withBucket(new StorageBucketConfig("collaboration").withConnection("local")
|
||||
.withBucketName("${env.S3_ATTACHMENTS_BUCKET}").withRole(BucketRole.PRIVATE).withIndexed(false)))
|
||||
.produce(i).addSelfToInstance(i));
|
||||
}
|
||||
|
||||
features.put("jobs", i -> new JobsQBitProducer().withQBitConfig(new JobsQBitConfig()
|
||||
.withSchedulerName(schedulerName)).produce(i).addSelfToInstance(i));
|
||||
features.put("collaboration", i -> new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig()
|
||||
@@ -376,11 +410,81 @@ final class EnterpriseFeatures
|
||||
}
|
||||
}
|
||||
|
||||
enableColumnStatistics(instance);
|
||||
auditDataTables(instance);
|
||||
addNavigationApps(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ turns QUERY_STATS (column statistics: counts, distinct values, min/max
|
||||
** and averages per field) off on every backend by default. Turn it on for the
|
||||
** backends whose module can aggregate, RDBMS (H2, SQLite, the sample's
|
||||
** in-memory H2) and the memory backend, so their tables offer statistics as
|
||||
** the web UI and the terminal clients expect; filesystem and API backends
|
||||
** cannot aggregate and stay without it. Tables of those backends that list
|
||||
** QUERY_STATS as disabled keep it off. The columnStats process that serves
|
||||
** the statistics is added when the instance lacks it, and runs through
|
||||
** GuardedColumnStatsStep (only fully readable fields).
|
||||
*******************************************************************************/
|
||||
static void enableColumnStatistics(QInstance instance)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// the UI API serves column statistics through this process //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
if(instance.getProcess(ColumnStatsStep.getProcessMetaData().getName()) == null)
|
||||
{
|
||||
instance.addProcess(ColumnStatsStep.getProcessMetaData());
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// run it through the guarded step: fields the caller cannot read in //
|
||||
// full are refused, and the results carry the analysed table so its //
|
||||
// field-access policy applies (QRun-IO/qqq#1367) //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
QProcessMetaData process = instance.getProcess(ColumnStatsStep.getProcessMetaData().getName());
|
||||
for(QStepMetaData step : process.getStepList())
|
||||
{
|
||||
if(step instanceof QBackendStepMetaData backendStep && backendStep.getCode() != null
|
||||
&& ColumnStatsStep.class.getName().equals(backendStep.getCode().getName()))
|
||||
{
|
||||
backendStep.setCode(new QCodeReference(GuardedColumnStatsStep.class));
|
||||
}
|
||||
}
|
||||
for(QBackendMetaData backend : instance.getBackends().values())
|
||||
{
|
||||
if(backend instanceof RDBMSBackendMetaData || new MemoryBackendModule().getBackendType().equals(backend.getBackendType()))
|
||||
{
|
||||
backend.withCapability(Capability.QUERY_STATS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Field-level audits on the demo and sample data tables (customer, order and
|
||||
** address get them with the audits feature), when audits are installed.
|
||||
*******************************************************************************/
|
||||
static void auditDataTables(QInstance instance)
|
||||
{
|
||||
if(instance.getTable("audit") == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(String tableName : List.of("customer", "order", "orderLine", "address", "person", "pet", "petNote", "carrier"))
|
||||
{
|
||||
QTableMetaData table = instance.getTable(tableName);
|
||||
if(table != null && table.getAuditRules() == null)
|
||||
{
|
||||
table.setAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The core profile has no apps and several qbits only add tables. Put the
|
||||
** demo tables in a "QQQ All" app and every visible table that no app reaches
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2026 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 https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Collection;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessTier;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.columnstats.ColumnStatsStep;
|
||||
|
||||
/**
|
||||
* Column statistics only for fields the caller may read in full.
|
||||
*
|
||||
* Statistics expose values (min, max, the most common values), so a field
|
||||
* whose access tier for the caller is REVEALABLE, MASKED or HIDDEN is refused
|
||||
* with a permission error before anything is computed. The result records are
|
||||
* then tagged with the analysed table, so the UI API applies that table's
|
||||
* field-access policy to them instead of withholding table-less records.
|
||||
* The process shape belongs in qqq core (QRun-IO/qqq#1367); this is the host's
|
||||
* safe check until then.
|
||||
*/
|
||||
public class GuardedColumnStatsStep extends ColumnStatsStep
|
||||
{
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String tableName = input.getValueString("tableName");
|
||||
String fieldName = input.getValueString("fieldName");
|
||||
QTableMetaData table = tableName == null ? null : QContext.getQInstance().getTable(tableName);
|
||||
assertFullAccess(table, fieldName);
|
||||
|
||||
super.run(input, output);
|
||||
|
||||
if(table != null)
|
||||
{
|
||||
for(Serializable value : output.getValues().values())
|
||||
{
|
||||
tag(table.getName(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse statistics for a field the caller cannot read in full; a joined
|
||||
* field ("otherTable.field") is checked on its own table.
|
||||
*/
|
||||
static void assertFullAccess(QTableMetaData table, String fieldName) throws QPermissionDeniedException
|
||||
{
|
||||
if(table == null || fieldName == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
QTableMetaData fieldTable = table;
|
||||
String name = fieldName;
|
||||
int dot = fieldName.indexOf('.');
|
||||
if(dot > 0 && QContext.getQInstance().getTable(fieldName.substring(0, dot)) != null)
|
||||
{
|
||||
fieldTable = QContext.getQInstance().getTable(fieldName.substring(0, dot));
|
||||
name = fieldName.substring(dot + 1);
|
||||
}
|
||||
FieldAccessTier tier = FieldAccessHelper.getTier(fieldTable, name);
|
||||
if(tier != FieldAccessTier.FULL)
|
||||
{
|
||||
String label = fieldTable.getField(name) == null ? name : fieldTable.getField(name).getLabel();
|
||||
throw new QPermissionDeniedException("Column statistics are not available for " + label
|
||||
+ ": you do not have full access to this field.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void tag(String tableName, Serializable value)
|
||||
{
|
||||
if(value instanceof QRecord record && record.getTableName() == null)
|
||||
{
|
||||
record.setTableName(tableName);
|
||||
}
|
||||
else if(value instanceof Collection<?> collection)
|
||||
{
|
||||
for(Object element : collection)
|
||||
{
|
||||
if(element instanceof QRecord record && record.getTableName() == null)
|
||||
{
|
||||
record.setTableName(tableName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2026 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 https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** The enterprise profile offers column statistics wherever the backend can aggregate. */
|
||||
class EnterpriseColumnStatsTest
|
||||
{
|
||||
@TempDir Path dataDirectory;
|
||||
|
||||
@Test
|
||||
void queryStatsOnAggregatingBackendsOnly() throws Exception
|
||||
{
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
|
||||
for(String tableName : new String[] {"customer", "order", "orderLine", "demoNote"})
|
||||
{
|
||||
assertFalse(hasQueryStats(instance, tableName), tableName + " before");
|
||||
}
|
||||
|
||||
EnterpriseFeatures.enableColumnStatistics(instance);
|
||||
assertNotNull(instance.getProcess("columnStats"));
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// H2 (customer, order), SQLite (orderLine) and memory (demoNote) //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
for(String tableName : new String[] {"customer", "order", "orderLine", "demoNote"})
|
||||
{
|
||||
assertTrue(hasQueryStats(instance, tableName), tableName);
|
||||
assertTrue(hasCapability(instance, tableName, Capability.TABLE_COUNT), tableName);
|
||||
assertTrue(hasCapability(instance, tableName, Capability.TABLE_EXPORT), tableName);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// the filesystem (product) and API (apiCatalog) backends do //
|
||||
// not aggregate, so they stay without statistics //
|
||||
////////////////////////////////////////////////////////////////
|
||||
assertFalse(hasQueryStats(instance, "product"));
|
||||
assertFalse(hasQueryStats(instance, "apiCatalog"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dataTablesAreAuditedWhenAuditsAreInstalled() throws Exception
|
||||
{
|
||||
QInstance instance = new QqqAllApplication(dataDirectory, "tcp://127.0.0.1:61616").defineQInstance();
|
||||
new AuditsMetaDataProvider().defineAll(instance, "h2", null);
|
||||
EnterpriseFeatures.auditDataTables(instance);
|
||||
for(String tableName : new String[] {"customer", "order", "orderLine", "address"})
|
||||
{
|
||||
assertEquals(AuditLevel.FIELD, instance.getTable(tableName).getAuditRules().getAuditLevel(), tableName);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasQueryStats(QInstance instance, String tableName)
|
||||
{
|
||||
return hasCapability(instance, tableName, Capability.QUERY_STATS);
|
||||
}
|
||||
|
||||
private static boolean hasCapability(QInstance instance, String tableName, Capability capability)
|
||||
{
|
||||
QTableMetaData table = instance.getTable(tableName);
|
||||
QBackendMetaData backend = instance.getBackendForTable(tableName);
|
||||
return table.isCapabilityEnabled(backend, capability);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2026 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 https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
package com.kingsrook.qqq.all;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
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.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
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.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Column statistics are refused for a field the caller cannot read in full. */
|
||||
class GuardedColumnStatsStepTest
|
||||
{
|
||||
private static final String KEY = "salaryViewer";
|
||||
|
||||
private QInstance instance;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws QException
|
||||
{
|
||||
MemoryRecordStore.fullReset();
|
||||
instance = new QInstance();
|
||||
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName(KEY));
|
||||
instance.addTable(new QTableMetaData().withName("employee").withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("salary", QFieldType.DECIMAL).withLabel("Salary")
|
||||
.withFieldSecurityLock(new FieldSecurityLock().withSecurityKeyType(KEY)
|
||||
.withDefaultBehavior(FieldSecurityLock.Behavior.MASK).withOverrideValues(List.of(true)))));
|
||||
EnterpriseFeatures.enableColumnStatistics(instance);
|
||||
|
||||
QContext.init(instance, new QSystemUserSession());
|
||||
new InsertAction().execute(new InsertInput("employee").withRecords(List.of(
|
||||
new QRecord().withValue("name", "Ada").withValue("salary", new BigDecimal("100")),
|
||||
new QRecord().withValue("name", "Grace").withValue("salary", new BigDecimal("200")))));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
MemoryRecordStore.fullReset();
|
||||
}
|
||||
|
||||
@Test
|
||||
void restrictedFieldIsRefusedWithoutTheTier() throws QException
|
||||
{
|
||||
QContext.init(instance, new QSession());
|
||||
QPermissionDeniedException denied = assertThrows(QPermissionDeniedException.class, () -> run("salary"));
|
||||
assertTrue(denied.getMessage().contains("Column statistics are not available for Salary"), denied.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restrictedFieldIsAllowedWithTheTier() throws QException
|
||||
{
|
||||
QContext.init(instance, new QSession().withSecurityKeyValue(KEY, true));
|
||||
RunProcessOutput output = run("salary");
|
||||
assertTrue(output.getException().isEmpty(), () -> output.getException().get().getMessage());
|
||||
QRecord stats = (QRecord) output.getValues().get("statsRecord");
|
||||
assertEquals(2, stats.getValueInteger("count"));
|
||||
assertEquals("employee", stats.getTableName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unrestrictedFieldIsAllowed() throws QException
|
||||
{
|
||||
QContext.init(instance, new QSession());
|
||||
RunProcessOutput output = run("name");
|
||||
assertTrue(output.getException().isEmpty(), () -> output.getException().get().getMessage());
|
||||
}
|
||||
|
||||
private RunProcessOutput run(String fieldName) throws QException
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName("columnStats");
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
input.addValue("tableName", "employee");
|
||||
input.addValue("fieldName", fieldName);
|
||||
return (new RunProcessAction().execute(input));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user