Build safe chart aggregation query models

This commit is contained in:
王鹏
2026-06-19 23:55:49 +08:00
parent 46140d4b6f
commit 77657b2cc9
4 changed files with 547 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
package com.ruoyi.generator.domain.front.dto.block;
public class ChartQueryParameter
{
private String name;
private Object value;
public ChartQueryParameter()
{
}
public ChartQueryParameter(String name, Object value)
{
this.name = name;
this.value = value;
}
public String getName()
{
return name;
}
public void setName(String name)
{
this.name = name;
}
public Object getValue()
{
return value;
}
public void setValue(Object value)
{
this.value = value;
}
}

View File

@@ -0,0 +1,41 @@
package com.ruoyi.generator.domain.front.dto.block;
import java.util.List;
public class ChartQueryRenderModel
{
private String tableName;
private String dimensionSql;
private String metricSelectSql;
private String whereSql;
private String groupBySql;
private String orderBySql;
private Integer limit;
private List<ChartQueryParameter> parameters;
private List<ChartMetricConfig> metrics;
private ChartDateRangeConfig dateRange;
private boolean metricOnly;
public String getTableName() { return tableName; }
public void setTableName(String tableName) { this.tableName = tableName; }
public String getDimensionSql() { return dimensionSql; }
public void setDimensionSql(String dimensionSql) { this.dimensionSql = dimensionSql; }
public String getMetricSelectSql() { return metricSelectSql; }
public void setMetricSelectSql(String metricSelectSql) { this.metricSelectSql = metricSelectSql; }
public String getWhereSql() { return whereSql; }
public void setWhereSql(String whereSql) { this.whereSql = whereSql; }
public String getGroupBySql() { return groupBySql; }
public void setGroupBySql(String groupBySql) { this.groupBySql = groupBySql; }
public String getOrderBySql() { return orderBySql; }
public void setOrderBySql(String orderBySql) { this.orderBySql = orderBySql; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public List<ChartQueryParameter> getParameters() { return parameters; }
public void setParameters(List<ChartQueryParameter> parameters) { this.parameters = parameters; }
public List<ChartMetricConfig> getMetrics() { return metrics; }
public void setMetrics(List<ChartMetricConfig> metrics) { this.metrics = metrics; }
public ChartDateRangeConfig getDateRange() { return dateRange; }
public void setDateRange(ChartDateRangeConfig dateRange) { this.dateRange = dateRange; }
public boolean isMetricOnly() { return metricOnly; }
public void setMetricOnly(boolean metricOnly) { this.metricOnly = metricOnly; }
}

View File

@@ -0,0 +1,243 @@
package com.ruoyi.generator.util;
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.generator.domain.GenTable;
import com.ruoyi.generator.domain.GenTableColumn;
import com.ruoyi.generator.domain.front.dto.block.BusinessBlockDefinition;
import com.ruoyi.generator.domain.front.dto.block.BusinessBlockInstance;
import com.ruoyi.generator.domain.front.dto.block.ChartDatasetConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartDateRangeConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartDimensionConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartFilterConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartMetricConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartQueryParameter;
import com.ruoyi.generator.domain.front.dto.block.ChartQueryRenderModel;
import com.ruoyi.generator.domain.front.dto.block.ChartSortConfig;
import org.springframework.stereotype.Component;
@Component
public class ChartBlockRenderSupport
{
public ChartQueryRenderModel build(GenTable table, BusinessBlockDefinition definition,
BusinessBlockInstance instance)
{
if (table == null || definition == null || instance == null || instance.getDataset() == null)
{
throw new ServiceException("图表查询配置不完整");
}
ChartDatasetConfig dataset = instance.getDataset();
boolean metricOnly = "metric".equalsIgnoreCase(StringUtils.defaultString(definition.getChartType()));
List<ChartMetricConfig> metrics = dataset.getMetrics() == null
? Collections.<ChartMetricConfig>emptyList() : dataset.getMetrics();
List<ChartQueryParameter> parameters = new ArrayList<ChartQueryParameter>();
String dimensionSql = metricOnly ? "" : dimensionSql(table, dataset.getDimension());
ChartQueryRenderModel model = new ChartQueryRenderModel();
model.setTableName(table.getTableName());
model.setDimensionSql(dimensionSql);
model.setMetricSelectSql(metricSelectSql(table, metrics));
model.setWhereSql(whereSql(table, dataset, parameters));
model.setGroupBySql(metricOnly ? "" : dimensionSql);
model.setOrderBySql(orderBySql(dataset.getSort(), metricOnly));
model.setLimit(dataset.getLimit());
model.setParameters(parameters);
model.setMetrics(metrics);
model.setDateRange(dataset.getDateRange());
model.setMetricOnly(metricOnly);
return model;
}
private String dimensionSql(GenTable table, ChartDimensionConfig dimension)
{
if (dimension == null)
{
throw new ServiceException("图表维度配置不存在");
}
String column = requireColumn(table, dimension.getField()).getColumnName();
String timeUnit = StringUtils.defaultString(dimension.getTimeUnit()).toLowerCase();
if ("day".equals(timeUnit))
{
return "DATE_FORMAT(t." + column + ", '%Y-%m-%d')";
}
if ("week".equals(timeUnit))
{
return "DATE_FORMAT(t." + column + ", '%x-W%v')";
}
if ("month".equals(timeUnit))
{
return "DATE_FORMAT(t." + column + ", '%Y-%m')";
}
return "t." + column;
}
private String metricSelectSql(GenTable table, List<ChartMetricConfig> metrics)
{
List<String> selections = new ArrayList<String>();
for (ChartMetricConfig metric : metrics)
{
String aggregate = StringUtils.defaultString(metric.getAggregate()).toLowerCase();
String column = StringUtils.isEmpty(metric.getField())
? "" : requireColumn(table, metric.getField()).getColumnName();
selections.add(metricSql(column, aggregate, metric.getAlias()));
}
return join(selections, ", ");
}
private String metricSql(String column, String aggregate, String alias)
{
if ("count".equals(aggregate))
{
return StringUtils.isEmpty(column)
? "COUNT(*) AS " + alias
: "COUNT(t." + column + ") AS " + alias;
}
if ("avg".equals(aggregate))
{
return "AVG(t." + column + ") AS " + alias;
}
return "SUM(t." + column + ") AS " + alias;
}
private String whereSql(GenTable table, ChartDatasetConfig dataset, List<ChartQueryParameter> parameters)
{
List<String> conditions = new ArrayList<String>();
List<ChartFilterConfig> filters = dataset.getFilters() == null
? Collections.<ChartFilterConfig>emptyList() : dataset.getFilters();
for (ChartFilterConfig filter : filters)
{
String column = requireColumn(table, filter.getField()).getColumnName();
String operator = StringUtils.defaultString(filter.getOperator()).toLowerCase();
addFilterCondition(conditions, parameters, column, operator, filter.getValue());
}
ChartDateRangeConfig dateRange = dataset.getDateRange();
if (dateRange != null)
{
String column = requireColumn(table, dateRange.getField()).getColumnName();
conditions.add("t." + column + " >= #{dateStart}");
conditions.add("t." + column + " < #{dateEnd}");
}
return join(conditions, " AND ");
}
private void addFilterCondition(List<String> conditions, List<ChartQueryParameter> parameters,
String column, String operator, Object value)
{
String columnSql = "t." + column;
if ("is_null".equals(operator))
{
conditions.add(columnSql + " IS NULL");
return;
}
if ("is_not_null".equals(operator))
{
conditions.add(columnSql + " IS NOT NULL");
return;
}
if ("between".equals(operator))
{
List<Object> values = values(value);
String first = addParameter(parameters, values.get(0));
String second = addParameter(parameters, values.get(1));
conditions.add(columnSql + " BETWEEN " + first + " AND " + second);
return;
}
if ("in".equals(operator))
{
List<String> placeholders = new ArrayList<String>();
for (Object item : values(value))
{
placeholders.add(addParameter(parameters, item));
}
conditions.add(columnSql + " IN (" + join(placeholders, ", ") + ")");
return;
}
String sqlOperator = operatorSql(operator);
conditions.add(columnSql + " " + sqlOperator + " " + addParameter(parameters, value));
}
private String operatorSql(String operator)
{
if ("ne".equals(operator)) return "<>";
if ("gt".equals(operator)) return ">";
if ("gte".equals(operator)) return ">=";
if ("lt".equals(operator)) return "<";
if ("lte".equals(operator)) return "<=";
return "=";
}
private String addParameter(List<ChartQueryParameter> parameters, Object value)
{
String name = "filter" + parameters.size();
parameters.add(new ChartQueryParameter(name, value));
return "#{" + name + "}";
}
private List<Object> values(Object value)
{
List<Object> values = new ArrayList<Object>();
if (value instanceof Collection)
{
values.addAll((Collection<?>) value);
return values;
}
if (value != null && value.getClass().isArray())
{
for (int index = 0; index < Array.getLength(value); index++)
{
values.add(Array.get(value, index));
}
return values;
}
values.add(value);
return values;
}
private String orderBySql(ChartSortConfig sort, boolean metricOnly)
{
if (sort == null)
{
return "";
}
String by = "dimension".equals(sort.getBy()) && !metricOnly ? "dimensionKey" : sort.getBy();
return by + " " + StringUtils.defaultString(sort.getOrder()).toUpperCase();
}
private GenTableColumn requireColumn(GenTable table, String field)
{
List<GenTableColumn> columns = table.getColumns() == null
? Collections.<GenTableColumn>emptyList() : table.getColumns();
for (GenTableColumn column : columns)
{
if (column != null && (StringUtils.equals(field, column.getColumnName())
|| StringUtils.equals(field, column.getJavaField())))
{
return column;
}
}
throw new ServiceException("图表字段不存在:" + table.getTableName() + "."
+ StringUtils.defaultString(field));
}
private String join(List<String> values, String delimiter)
{
StringBuilder result = new StringBuilder();
for (String value : values)
{
if (result.length() > 0)
{
result.append(delimiter);
}
result.append(value);
}
return result.toString();
}
}

View File

@@ -0,0 +1,226 @@
package com.ruoyi.generator.util;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import java.util.Arrays;
import java.util.Collections;
import org.junit.Test;
import com.ruoyi.generator.domain.GenTable;
import com.ruoyi.generator.domain.GenTableColumn;
import com.ruoyi.generator.domain.front.dto.block.BusinessBlockDefinition;
import com.ruoyi.generator.domain.front.dto.block.BusinessBlockInstance;
import com.ruoyi.generator.domain.front.dto.block.ChartDatasetConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartDateRangeConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartDimensionConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartFilterConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartMetricConfig;
import com.ruoyi.generator.domain.front.dto.block.ChartQueryRenderModel;
import com.ruoyi.generator.domain.front.dto.block.ChartSortConfig;
public class ChartBlockRenderSupportTest
{
private final ChartBlockRenderSupport support = new ChartBlockRenderSupport();
@Test
public void rendersBoundSingleTableAggregation()
{
ChartQueryRenderModel model = support.build(orderTable(), definition("line"), lineInstance());
assertEquals("shop_order", model.getTableName());
assertEquals("DATE_FORMAT(t.create_time, '%Y-%m-%d')", model.getDimensionSql());
assertEquals("DATE_FORMAT(t.create_time, '%Y-%m-%d')", model.getGroupBySql());
assertTrue(model.getMetricSelectSql().contains("SUM(t.amount) AS salesAmount"));
assertTrue(model.getWhereSql().contains("t.status = #{filter0}"));
assertEquals("filter0", model.getParameters().get(0).getName());
assertEquals("PAID", model.getParameters().get(0).getValue());
assertEquals("dimensionKey ASC", model.getOrderBySql());
assertEquals(Integer.valueOf(100), model.getLimit());
assertFalse(model.isMetricOnly());
}
@Test
public void neverCopiesFilterValuesIntoSql()
{
BusinessBlockInstance instance = lineInstance();
instance.getDataset().getFilters().get(0).setValue("PAID' OR 1=1 --");
ChartQueryRenderModel model = support.build(orderTable(), definition("line"), instance);
assertTrue(model.getWhereSql().contains("#{filter0}"));
assertFalse(model.getWhereSql().contains("OR 1=1"));
}
@Test
public void formatsDayWeekAndMonthDimensionsFromResolvedColumns()
{
assertDimension("day", "DATE_FORMAT(t.create_time, '%Y-%m-%d')");
assertDimension("week", "DATE_FORMAT(t.create_time, '%x-W%v')");
assertDimension("month", "DATE_FORMAT(t.create_time, '%Y-%m')");
assertDimension(null, "t.create_time");
}
@Test
public void rendersCountSumAndAverageMetrics()
{
BusinessBlockInstance instance = lineInstance();
instance.getDataset().setMetrics(Arrays.asList(
metric(null, "count", "orderCount"),
metric("amount", "sum", "salesAmount"),
metric("amount", "avg", "averageAmount")));
ChartQueryRenderModel model = support.build(orderTable(), definition("line"), instance);
assertEquals("COUNT(*) AS orderCount, SUM(t.amount) AS salesAmount, AVG(t.amount) AS averageAmount",
model.getMetricSelectSql());
assertEquals(3, model.getMetrics().size());
}
@Test
public void rendersBetweenInNullAndDateRangeConditionsAsBoundParameters()
{
BusinessBlockInstance instance = lineInstance();
ChartDateRangeConfig dateRange = new ChartDateRangeConfig();
dateRange.setField("createTime");
dateRange.setPreset("last_30_days");
instance.getDataset().setDateRange(dateRange);
instance.getDataset().setFilters(Arrays.asList(
filter("amount", "between", Arrays.asList(10, 20)),
filter("status", "in", new String[] { "PAID", "SHIPPED" }),
filter("createTime", "is_not_null", null),
filter("id", "is_null", null)));
ChartQueryRenderModel model = support.build(orderTable(), definition("line"), instance);
assertTrue(model.getWhereSql().contains("t.amount BETWEEN #{filter0} AND #{filter1}"));
assertTrue(model.getWhereSql().contains("t.status IN (#{filter2}, #{filter3})"));
assertTrue(model.getWhereSql().contains("t.create_time IS NOT NULL"));
assertTrue(model.getWhereSql().contains("t.id IS NULL"));
assertTrue(model.getWhereSql().contains("t.create_time >= #{dateStart}"));
assertTrue(model.getWhereSql().contains("t.create_time < #{dateEnd}"));
assertEquals(Arrays.asList(10, 20, "PAID", "SHIPPED"), Arrays.asList(
model.getParameters().get(0).getValue(),
model.getParameters().get(1).getValue(),
model.getParameters().get(2).getValue(),
model.getParameters().get(3).getValue()));
assertSame(dateRange, model.getDateRange());
}
@Test
public void rendersMetricOnlyQueryWithoutDimensionOrGrouping()
{
BusinessBlockInstance instance = lineInstance();
instance.getDataset().setDimension(null);
instance.getDataset().setMetrics(Collections.singletonList(metric("id", "count", "orderCount")));
instance.getDataset().setSort(null);
instance.getDataset().setLimit(Integer.valueOf(1));
ChartQueryRenderModel model = support.build(orderTable(), definition("metric"), instance);
assertTrue(model.isMetricOnly());
assertEquals("", model.getDimensionSql());
assertEquals("", model.getGroupBySql());
assertEquals("", model.getOrderBySql());
assertEquals("COUNT(t.id) AS orderCount", model.getMetricSelectSql());
assertEquals(Integer.valueOf(1), model.getLimit());
}
@Test
public void rendersMetricAliasSortAndConfiguredChartLimits()
{
BusinessBlockInstance instance = lineInstance();
instance.getDataset().getSort().setBy("salesAmount");
instance.getDataset().getSort().setOrder("desc");
instance.getDataset().setLimit(Integer.valueOf(20));
ChartQueryRenderModel model = support.build(orderTable(), definition("bar"), instance);
assertEquals("salesAmount DESC", model.getOrderBySql());
assertEquals(Integer.valueOf(20), model.getLimit());
instance.getDataset().setLimit(Integer.valueOf(10));
assertEquals(Integer.valueOf(10),
support.build(orderTable(), definition("pie"), instance).getLimit());
}
private void assertDimension(String timeUnit, String expected)
{
BusinessBlockInstance instance = lineInstance();
instance.getDataset().getDimension().setField("createTime");
instance.getDataset().getDimension().setTimeUnit(timeUnit);
assertEquals(expected, support.build(orderTable(), definition("line"), instance).getDimensionSql());
}
private BusinessBlockDefinition definition(String chartType)
{
BusinessBlockDefinition definition = new BusinessBlockDefinition();
definition.setKind("chart");
definition.setChartType(chartType);
return definition;
}
private BusinessBlockInstance lineInstance()
{
ChartDimensionConfig dimension = new ChartDimensionConfig();
dimension.setField("create_time");
dimension.setTimeUnit("day");
ChartSortConfig sort = new ChartSortConfig();
sort.setBy("dimension");
sort.setOrder("asc");
ChartDatasetConfig dataset = new ChartDatasetConfig();
dataset.setTable("shop_order");
dataset.setDimension(dimension);
dataset.setMetrics(Collections.singletonList(metric("amount", "sum", "salesAmount")));
dataset.setFilters(Collections.singletonList(filter("status", "eq", "PAID")));
dataset.setSort(sort);
dataset.setLimit(Integer.valueOf(100));
BusinessBlockInstance instance = new BusinessBlockInstance();
instance.setId("admin_line_chart_001");
instance.setDataset(dataset);
return instance;
}
private ChartMetricConfig metric(String field, String aggregate, String alias)
{
ChartMetricConfig metric = new ChartMetricConfig();
metric.setField(field);
metric.setAggregate(aggregate);
metric.setAlias(alias);
metric.setLabel(alias);
return metric;
}
private ChartFilterConfig filter(String field, String operator, Object value)
{
ChartFilterConfig filter = new ChartFilterConfig();
filter.setField(field);
filter.setOperator(operator);
filter.setValue(value);
return filter;
}
private GenTable orderTable()
{
GenTable table = new GenTable();
table.setTableName("shop_order");
table.setColumns(Arrays.asList(
column("id", "id"),
column("amount", "amount"),
column("status", "status"),
column("create_time", "createTime")));
return table;
}
private GenTableColumn column(String columnName, String javaField)
{
GenTableColumn column = new GenTableColumn();
column.setColumnName(columnName);
column.setJavaField(javaField);
return column;
}
}