diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartQueryParameter.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartQueryParameter.java new file mode 100644 index 0000000..01c1892 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartQueryParameter.java @@ -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; + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartQueryRenderModel.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartQueryRenderModel.java new file mode 100644 index 0000000..7f16cc0 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartQueryRenderModel.java @@ -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 parameters; + private List 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 getParameters() { return parameters; } + public void setParameters(List parameters) { this.parameters = parameters; } + public List getMetrics() { return metrics; } + public void setMetrics(List 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; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/util/ChartBlockRenderSupport.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/util/ChartBlockRenderSupport.java new file mode 100644 index 0000000..2f3b7a2 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/util/ChartBlockRenderSupport.java @@ -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 metrics = dataset.getMetrics() == null + ? Collections.emptyList() : dataset.getMetrics(); + List parameters = new ArrayList(); + + 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 metrics) + { + List selections = new ArrayList(); + 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 parameters) + { + List conditions = new ArrayList(); + List filters = dataset.getFilters() == null + ? Collections.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 conditions, List 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 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 placeholders = new ArrayList(); + 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 parameters, Object value) + { + String name = "filter" + parameters.size(); + parameters.add(new ChartQueryParameter(name, value)); + return "#{" + name + "}"; + } + + private List values(Object value) + { + List values = new ArrayList(); + 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 columns = table.getColumns() == null + ? Collections.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 values, String delimiter) + { + StringBuilder result = new StringBuilder(); + for (String value : values) + { + if (result.length() > 0) + { + result.append(delimiter); + } + result.append(value); + } + return result.toString(); + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/util/ChartBlockRenderSupportTest.java b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/util/ChartBlockRenderSupportTest.java new file mode 100644 index 0000000..1d1053b --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/util/ChartBlockRenderSupportTest.java @@ -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; + } +}