fix(generator): 修复 create_record 必填字段生成流程

This commit is contained in:
王鹏
2026-07-15 17:28:49 +08:00
parent 8247c86862
commit 84d3c8fc05
15 changed files with 515 additions and 52 deletions

View File

@@ -7,6 +7,7 @@ public class BusinessActionDesign
{
private String code;
private String name;
private String actor;
private String ownerTable;
private String method;
private String path;
@@ -36,6 +37,8 @@ public class BusinessActionDesign
public void setCode(String code) { this.code = code; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getActor() { return actor; }
public void setActor(String actor) { this.actor = actor; }
public String getOwnerTable() { return ownerTable; }
public void setOwnerTable(String ownerTable) { this.ownerTable = ownerTable; }
public String getMethod() { return method; }

View File

@@ -56,6 +56,7 @@ public class TypedBusinessActionRenderModel
private boolean currentUserRequired;
private String auditUserSetter;
private String auditTimeSetter;
private List<RequestParameter> requestParameters = new ArrayList<RequestParameter>();
private List<Effect> effects = new ArrayList<Effect>();
public String getCode() { return code; }
@@ -104,10 +105,29 @@ public class TypedBusinessActionRenderModel
public void setAuditUserSetter(String auditUserSetter) { this.auditUserSetter = auditUserSetter; }
public String getAuditTimeSetter() { return auditTimeSetter; }
public void setAuditTimeSetter(String auditTimeSetter) { this.auditTimeSetter = auditTimeSetter; }
public List<RequestParameter> getRequestParameters() { return requestParameters; }
public void setRequestParameters(List<RequestParameter> requestParameters) { this.requestParameters = requestParameters; }
public List<Effect> getEffects() { return effects; }
public void setEffects(List<Effect> effects) { this.effects = effects; }
}
public static class RequestParameter
{
private String field;
private String variable;
private String javaType;
private String controllerExpression;
public String getField() { return field; }
public void setField(String field) { this.field = field; }
public String getVariable() { return variable; }
public void setVariable(String variable) { this.variable = variable; }
public String getJavaType() { return javaType; }
public void setJavaType(String javaType) { this.javaType = javaType; }
public String getControllerExpression() { return controllerExpression; }
public void setControllerExpression(String controllerExpression) { this.controllerExpression = controllerExpression; }
}
public static class Effect
{
private String type;

View File

@@ -284,6 +284,7 @@ public class FlowConfigCompiler
BusinessActionDesign action = new BusinessActionDesign();
action.setCode(source.getCode());
action.setName(source.getName());
action.setActor(source.getActor());
action.setOwnerTable(ownerTable(config, source));
action.setMethod(defaultValue(source.getMethod(), "POST"));
action.setPath(defaultValue(source.getPath(), "/" + source.getCode()));

View File

@@ -120,6 +120,7 @@ public class BusinessLoopDatabaseContractReconciler
}
repairAuditFields(tables, flowConfig, maxColumnsPerTable, messages);
repairEffectFields(tables, flowConfig, maxColumnsPerTable, messages);
repairCreateRecordSourceRequirements(tables, flowConfig, messages);
if (!messages.isEmpty())
{
List<String> allMessages = database.getRepairMessages() == null
@@ -183,6 +184,43 @@ public class BusinessLoopDatabaseContractReconciler
}
}
private void repairCreateRecordSourceRequirements(Map<String, DatabaseTableDesign> tables,
SimpleFlowConfig flowConfig, List<String> messages)
{
if (flowConfig == null) return;
for (SimpleFlowConfig flow : FlowConfigSupport.leafFlows(flowConfig))
{
for (Action action : safe(flow.getActions()))
{
if (action == null) continue;
String ownerTableName = StringUtils.defaultIfBlank(action.getOwnerTable(), flow.getMainTable());
DatabaseTableDesign ownerTable = tables.get(normalize(ownerTableName));
for (Effect effect : safe(action.getEffects()))
{
if (effect == null || !"create_record".equals(effect.getType())) continue;
DatabaseTableDesign targetTable = tables.get(normalize(effect.getTable()));
for (Map.Entry<String, String> value : effect.getValues().entrySet())
{
String expression = StringUtils.defaultString(value.getValue()).trim();
if (!expression.startsWith("record.")) continue;
DatabaseColumnDesign target = findColumn(targetTable, value.getKey());
DatabaseColumnDesign source = findColumn(ownerTable,
expression.substring("record.".length()));
if (target == null || source == null || !"1".equals(target.getIsRequired())
|| "1".equals(source.getIsRequired()))
{
continue;
}
source.setIsRequired("1");
messages.add("Required create_record source field: " + ownerTableName + "."
+ source.getColumnName() + " -> " + effect.getTable() + "."
+ target.getColumnName());
}
}
}
}
}
private DatabaseColumnDesign primaryKey(DatabaseTableDesign table)
{
for (DatabaseColumnDesign column : table == null

View File

@@ -19,7 +19,6 @@ import com.ruoyi.generator.domain.front.dto.SimpleFlowConfig.Effect;
import com.ruoyi.generator.domain.front.dto.SimpleFlowConfig.Metric;
import com.ruoyi.generator.factory.flow.FlowConfigCompilation;
import com.ruoyi.generator.factory.flow.FlowConfigSupport;
import com.ruoyi.generator.util.CreateRecordFieldSupport;
/** Validates compiled flow actions against the database before it is persisted. */
@Component
@@ -176,38 +175,17 @@ public class FlowConfigDatabaseContractValidator
throw new ServiceException("Flow config value types do not match: " + expression
+ " -> " + targetTable + "." + value.getKey());
}
if ("create_record".equals(effect.getType())
&& "1".equals(target.getIsRequired())
&& !"1".equals(source.getIsRequired()))
{
throw new ServiceException("Flow config create_record source may be null for required field: "
+ ownerTable + "." + source.getColumnName() + " -> "
+ targetTable + "." + target.getColumnName());
}
}
else validateLiteral(expression, target, targetTable + "." + value.getKey());
}
if ("create_record".equals(effect.getType()))
{
validateRequiredCreateFields(schema.get(normalize(targetTable)), effect, targetTable);
}
}
private void validateRequiredCreateFields(Map<String, DatabaseColumnDesign> columns, Effect effect,
String targetTable)
{
if (columns == null) return;
Map<String, String> values = effect.getValues();
for (DatabaseColumnDesign column : columns.values())
{
if (column == null || !"1".equals(column.getIsRequired())) continue;
if ("1".equals(column.getIsIncrement()) || hasUsableDefault(column.getDefaultValue())) continue;
if (values != null && values.containsKey(column.getColumnName())) continue;
if (CreateRecordFieldSupport.isCreationTimeField(
column.getColumnName(), column.getJavaField()) && family(column) == TypeFamily.TEMPORAL)
{
continue;
}
throw new ServiceException("Flow config create_record missing required field: "
+ targetTable + "." + column.getColumnName());
}
}
private boolean hasUsableDefault(String defaultValue)
{
return StringUtils.isNotBlank(defaultValue) && !"null".equalsIgnoreCase(defaultValue.trim());
}
private boolean requiresNumericMetric(Metric metric)

View File

@@ -205,7 +205,7 @@ public final class BusinessActionRenderSupport
{
continue;
}
String expression = automaticInsertValueExpression(column);
String expression = automaticInsertValueExpression(action, column);
if (!isEmpty(expression))
{
values.put(column.getColumnName(), expression);
@@ -222,13 +222,14 @@ public final class BusinessActionRenderSupport
&& !values.containsKey(column.getColumnName());
}
private static String automaticInsertValueExpression(GenTableColumn column)
private static String automaticInsertValueExpression(BusinessActionDesign action, GenTableColumn column)
{
if (isCreationTimeColumn(column))
{
return "${now}";
}
if (CurrentUserFieldSupport.isCurrentUserIdField(column))
if (CurrentUserFieldSupport.isCurrentUserIdField(column,
action == null ? null : action.getActor()))
{
return "${current_user.id}";
}
@@ -328,10 +329,8 @@ public final class BusinessActionRenderSupport
}
private static boolean isCreationTimeColumn(GenTableColumn column)
{
String columnName = normalize(column.getColumnName());
String javaField = normalize(column.getJavaField());
boolean creationField = "created_at".equals(columnName) || "create_time".equals(columnName)
|| "createdat".equals(javaField) || "createtime".equals(javaField);
boolean creationField = CreateRecordFieldSupport.isCreationTimeField(
column.getColumnName(), column.getJavaField());
String columnType = normalize(column.getColumnType());
String javaType = normalize(column.getJavaType());
return creationField && (columnType.contains("date") || columnType.contains("time")

View File

@@ -23,6 +23,15 @@ public final class CurrentUserFieldSupport
column.getColumnComment());
}
public static boolean isCurrentUserIdField(GenTableColumn column, String actor)
{
if (isCurrentUserIdField(column)) return true;
if (column == null || StringUtils.isBlank(actor)) return false;
String actorId = compact(actor) + "id";
return actorId.equals(compact(column.getColumnName()))
|| actorId.equals(compact(column.getJavaField()));
}
public static boolean isCurrentUserIdField(FrontProjectColumn column)
{
return column != null && isCurrentUserIdField(column.getColumnName(), column.getJavaField(),
@@ -111,4 +120,4 @@ public final class CurrentUserFieldSupport
{
return StringUtils.defaultString(value).trim().toLowerCase(Locale.ENGLISH).replaceAll("[^a-z0-9]+", "");
}
}
}

View File

@@ -18,6 +18,7 @@ import com.ruoyi.generator.domain.render.TypedBusinessActionRenderModel.Action;
import com.ruoyi.generator.domain.render.TypedBusinessActionRenderModel.Assignment;
import com.ruoyi.generator.domain.render.TypedBusinessActionRenderModel.Dependency;
import com.ruoyi.generator.domain.render.TypedBusinessActionRenderModel.Effect;
import com.ruoyi.generator.domain.render.TypedBusinessActionRenderModel.RequestParameter;
/** Converts flow_config to explicit, strongly typed Java render instructions. */
public final class TypedBusinessActionCompiler
@@ -94,9 +95,6 @@ public final class TypedBusinessActionCompiler
}
}
action.setCurrentUserRequired(currentUserRequired);
action.setServiceParameters(pk.getJavaType() + " " + pk.getJavaField()
+ (currentUserRequired ? ", Long currentUserId" : ""));
action.setServiceArguments(pk.getJavaField() + (currentUserRequired ? ", currentUser.getUserId()" : ""));
action.setControllerIdExpression(idExpression(pk.getJavaType(), "params.get(\"" + pk.getJavaField() + "\")"));
if (source.getAudit() != null)
{
@@ -116,14 +114,15 @@ public final class TypedBusinessActionCompiler
for (SimpleFlowConfig.Effect sourceEffect : source.getEffects())
{
action.getEffects().add(compileEffect(model, sourceEffect, action, owner, tables,
dependencies, index++));
dependencies, source.getActor(), index++));
}
configureActionParameters(action, pk);
return action;
}
private static Effect compileEffect(TypedBusinessActionRenderModel model, SimpleFlowConfig.Effect source,
Action action, GenTable owner, Map<String, GenTable> tables, Map<String, Dependency> dependencies,
int index)
String actor, int index)
{
GenTable target = tables.get(source.getTable());
if (target == null) throw new ServiceException("flow_config 引用了不存在的表: " + source.getTable());
@@ -165,27 +164,89 @@ public final class TypedBusinessActionCompiler
}
if ("create_record".equals(source.getType()))
{
appendImplicitCreationTimeAssignments(model, effect, target, assignedFields);
appendImplicitCreateAssignments(model, effect, target, assignedFields, action, actor);
}
}
return effect;
}
private static void appendImplicitCreationTimeAssignments(TypedBusinessActionRenderModel model, Effect effect,
GenTable target, Map<String, String> assignedFields)
private static void appendImplicitCreateAssignments(TypedBusinessActionRenderModel model, Effect effect,
GenTable target, Map<String, String> assignedFields, Action action, String actor)
{
if (target.getColumns() == null) return;
for (GenTableColumn column : target.getColumns())
{
if (assignedFields.containsKey(column.getColumnName())
|| assignedFields.containsValue(column.getJavaField())) continue;
if (!CreateRecordFieldSupport.isCreationTimeField(
column.getColumnName(), column.getJavaField())) continue;
String expression = currentTimeExpression(model, column.getJavaType());
if (column.isIncrement()) continue;
String expression = null;
if (CreateRecordFieldSupport.isCreationTimeField(
column.getColumnName(), column.getJavaField()))
{
expression = currentTimeExpression(model, column.getJavaType());
}
else if (!column.isRequired())
{
continue;
}
else if (CurrentUserFieldSupport.isCurrentUserIdField(column, actor))
{
action.setCurrentUserRequired(true);
expression = "currentUserId";
}
else
{
expression = ensureRequestParameter(model, action, column).getVariable();
}
if (expression != null) addAssignment(effect, column, expression);
}
}
private static RequestParameter ensureRequestParameter(TypedBusinessActionRenderModel model,
Action action, GenTableColumn column)
{
for (RequestParameter existing : action.getRequestParameters())
{
if (!StringUtils.equals(existing.getField(), column.getColumnName())) continue;
if (!StringUtils.equals(existing.getJavaType(), renderJavaType(column.getJavaType())))
{
throw new ServiceException("create_record input field type conflict: "
+ column.getColumnName());
}
return existing;
}
String javaType = renderJavaType(column.getJavaType());
RequestParameter parameter = new RequestParameter();
parameter.setField(column.getColumnName());
parameter.setVariable("input" + Character.toUpperCase(column.getJavaField().charAt(0))
+ column.getJavaField().substring(1));
parameter.setJavaType(javaType);
parameter.setControllerExpression(requestParameterExpression(model, javaType,
"params.get(\"" + javaEscape(column.getColumnName()) + "\")",
column.getColumnName()));
action.getRequestParameters().add(parameter);
return parameter;
}
private static void configureActionParameters(Action action, GenTableColumn pk)
{
StringBuilder parameters = new StringBuilder(pk.getJavaType()).append(" ").append(pk.getJavaField());
StringBuilder arguments = new StringBuilder(pk.getJavaField());
if (action.isCurrentUserRequired())
{
parameters.append(", Long currentUserId");
arguments.append(", currentUser.getUserId()");
}
for (RequestParameter parameter : action.getRequestParameters())
{
parameters.append(", ").append(parameter.getJavaType()).append(" ")
.append(parameter.getVariable());
arguments.append(", ").append(parameter.getVariable());
}
action.setServiceParameters(parameters.toString());
action.setServiceArguments(arguments.toString());
}
private static String currentTimeExpression(TypedBusinessActionRenderModel model, String javaType)
{
if ("Date".equals(javaType) || "java.util.Date".equals(javaType))
@@ -275,6 +336,48 @@ public final class TypedBusinessActionCompiler
return normalized;
}
private static String renderJavaType(String javaType)
{
return StringUtils.defaultIfBlank(javaType, "String");
}
private static String requestParameterExpression(TypedBusinessActionRenderModel model,
String javaType, String source, String field)
{
if ("String".equals(javaType)) return "String.valueOf(" + source + ")";
if ("Integer".equals(javaType) || "int".equals(javaType))
return "Integer.valueOf(String.valueOf(" + source + "))";
if ("Long".equals(javaType) || "long".equals(javaType))
return "Long.valueOf(String.valueOf(" + source + "))";
if ("Short".equals(javaType) || "short".equals(javaType))
return "Short.valueOf(String.valueOf(" + source + "))";
if ("Double".equals(javaType) || "double".equals(javaType))
return "Double.valueOf(String.valueOf(" + source + "))";
if ("Float".equals(javaType) || "float".equals(javaType))
return "Float.valueOf(String.valueOf(" + source + "))";
if ("Boolean".equals(javaType) || "boolean".equals(javaType))
return "Boolean.valueOf(String.valueOf(" + source + "))";
if ("BigDecimal".equals(javaType) || "java.math.BigDecimal".equals(javaType))
{
model.setUsesBigDecimal(true);
return "new BigDecimal(String.valueOf(" + source + "))";
}
if ("Date".equals(javaType) || "java.util.Date".equals(javaType))
{
model.setUsesDate(true);
return "parseBusinessActionDate(" + source + ", \"" + javaEscape(field) + "\")";
}
if ("LocalDate".equals(javaType) || "java.time.LocalDate".equals(javaType))
return "java.time.LocalDate.parse(String.valueOf(" + source + "))";
if ("LocalDateTime".equals(javaType) || "java.time.LocalDateTime".equals(javaType))
return "java.time.LocalDateTime.parse(String.valueOf(" + source + "))";
if ("Instant".equals(javaType) || "java.time.Instant".equals(javaType))
return "java.time.Instant.parse(String.valueOf(" + source + "))";
if ("Timestamp".equals(javaType) || "java.sql.Timestamp".equals(javaType))
return "java.sql.Timestamp.valueOf(String.valueOf(" + source + ").replace('T', ' '))";
return "(" + javaType + ") " + source;
}
private static String idExpression(String javaType, String source)
{
if ("String".equals(javaType)) return "String.valueOf(" + source + ")";

View File

@@ -13,7 +13,13 @@ import ${packageName}.security.PortalAuthTokenStore;
#end
import ${packageName}.service.I${ClassName}Service;
import java.io.InputStream;
#if($typedBusinessUsesBigDecimal)
import java.math.BigDecimal;
#end
import java.net.URLEncoder;
#if($typedBusinessUsesDate)
import java.util.Date;
#end
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
@@ -179,6 +185,13 @@ public class ${ClassName}Controller
}
AuthPrincipal currentUser = requireCurrentUser(request);
${action.pkType} ${action.pkField} = ${action.controllerIdExpression};
#foreach($parameter in $action.requestParameters)
if (!hasValue(params.get("${parameter.field}")))
{
throw new IllegalArgumentException("Missing business action parameter: ${parameter.field}");
}
${parameter.javaType} ${parameter.variable} = ${parameter.controllerExpression};
#end
${className}Service.${action.javaMethodName}(${action.serviceArguments});
return Result.success();
}
@@ -200,6 +213,24 @@ public class ${ClassName}Controller
}
#end
#if($hasTypedBusinessActions)
#if($typedBusinessUsesDate)
private Date parseBusinessActionDate(Object value, String field)
{
String text = String.valueOf(value).trim().replace('T', ' ');
String pattern = text.length() <= 10 ? "yyyy-MM-dd" : "yyyy-MM-dd HH:mm:ss";
try
{
return new java.text.SimpleDateFormat(pattern).parse(text);
}
catch (java.text.ParseException e)
{
throw new IllegalArgumentException("Invalid business action date parameter: " + field);
}
}
#end
#end
private boolean hasValue(Object value)
{
return value != null && StringUtils.isNotBlank(String.valueOf(value));

View File

@@ -2,6 +2,12 @@ package ${packageName}.service;
import ${packageName}.entity.${ClassName};
import com.baomidou.mybatisplus.extension.service.IService;
#if($typedBusinessUsesBigDecimal)
import java.math.BigDecimal;
#end
#if($typedBusinessUsesDate)
import java.util.Date;
#end
/**
* ${functionName}Service接口

View File

@@ -17,6 +17,7 @@ import com.ruoyi.generator.domain.front.dto.DatabaseTableDesign;
import com.ruoyi.generator.domain.front.dto.SimpleFlowConfig;
import com.ruoyi.generator.domain.front.dto.SimpleFlowConfig.Action;
import com.ruoyi.generator.domain.front.dto.SimpleFlowConfig.Audit;
import com.ruoyi.generator.domain.front.dto.SimpleFlowConfig.Effect;
public class BusinessLoopDatabaseContractReconcilerTest
{
@@ -120,6 +121,40 @@ public class BusinessLoopDatabaseContractReconcilerTest
new FlowConfigDatabaseContractValidator().validate(database, flowConfig, null);
}
@Test
public void requiresNullableSourceUsedByRequiredCreateRecordTarget()
{
DatabaseColumnDesign opportunityAmount = column("amount", "decimal(10,2)");
DatabaseTableDesign opportunity = new DatabaseTableDesign();
opportunity.setTableName("opportunity");
opportunity.setColumns(Arrays.asList(column("id", "bigint(20)"), opportunityAmount));
DatabaseColumnDesign contractAmount = column("amount", "decimal(10,2)");
contractAmount.setIsRequired("1");
DatabaseTableDesign contract = new DatabaseTableDesign();
contract.setTableName("contract");
contract.setColumns(Arrays.asList(column("id", "bigint(20)"), contractAmount));
DatabaseDesignResponse database = new DatabaseDesignResponse();
database.setTables(Arrays.asList(opportunity, contract));
Effect create = new Effect();
create.setType("create_record");
create.setTable("contract");
create.getValues().put("amount", "record.amount");
Action action = new Action();
action.setOwnerTable("opportunity");
action.setEffects(Collections.singletonList(create));
SimpleFlowConfig flowConfig = new SimpleFlowConfig();
flowConfig.setMainTable("contract");
flowConfig.setActions(Collections.singletonList(action));
java.util.List<String> messages = reconciler.repair(database, new BusinessLoopPlan(),
flowConfig, 30);
assertEquals("1", opportunityAmount.getIsRequired());
assertTrue(messages.toString(), messages.toString().contains(
"opportunity.amount -> contract.amount"));
}
private BusinessLoopPlan metricPlan(String field, String aggregate, String unit)
{
BusinessLoopMetric metric = new BusinessLoopMetric();

View File

@@ -124,7 +124,7 @@ public class FlowConfigDatabaseContractValidatorTest
}
@Test
public void rejectsCreateRecordThatOmitsNonGeneratedRequiredField()
public void acceptsCreateRecordThatUsesImplicitInputForRequiredField()
{
DatabaseTableDesign book = table("book_info", "id");
DatabaseTableDesign borrow = table("borrow_record", "id", "book_id", "reader_id", "status");
@@ -143,14 +143,41 @@ public class FlowConfigDatabaseContractValidatorTest
+ "\"reader_id\":\"currentUserId\",\"status\":\"pending\"}}]}]}",
SimpleFlowConfig.class);
validator.validate(database, flow, new FlowConfigCompiler().compile(flow));
}
@Test
public void rejectsNullableRecordSourceForRequiredCreateField()
{
DatabaseTableDesign opportunity = table("opportunity", "id", "status");
DatabaseColumnDesign amount = column("amount");
amount.setColumnType("decimal(10,2)");
opportunity.getColumns().add(amount);
DatabaseTableDesign contract = table("contract", "id", "status");
contract.getColumns().add(requiredColumn("amount", "decimal(10,2)"));
DatabaseDesignResponse database = new DatabaseDesignResponse();
database.setTables(Arrays.asList(opportunity, contract));
database.setBusinessActions(Collections.<BusinessActionDesign>emptyList());
SimpleFlowConfig flow = JSON.parseObject("{"
+ "\"code\":\"contract_flow\",\"name\":\"Contract\","
+ "\"mainTable\":\"contract\",\"statusField\":\"status\","
+ "\"states\":[{\"code\":\"pending\",\"label\":\"Pending\"},"
+ "{\"code\":\"active\",\"label\":\"Active\"}],"
+ "\"actions\":[{\"code\":\"create_contract\",\"name\":\"Create contract\","
+ "\"ownerTable\":\"opportunity\",\"effects\":[{\"type\":\"create_record\","
+ "\"table\":\"contract\",\"values\":{\"amount\":\"record.amount\","
+ "\"status\":\"pending\"}}]}]}",
SimpleFlowConfig.class);
try
{
validator.validate(database, flow, new FlowConfigCompiler().compile(flow));
fail("Expected missing required create_record field validation to fail");
fail("Expected nullable create_record source validation to fail");
}
catch (ServiceException e)
{
assertEquals("Flow config create_record missing required field: borrow_record.tenant_id", e.getMessage());
assertEquals("Flow config create_record source may be null for required field: "
+ "opportunity.amount -> contract.amount", e.getMessage());
}
}

View File

@@ -148,6 +148,28 @@ public class BusinessActionRenderSupportTest
assertEquals(Arrays.asList("book_id", "due_date"), fields(action));
}
@Test
public void usesCustomActionActorIdAsTrustedCurrentUserInput()
{
BusinessActionDesign action = new BusinessActionDesign();
action.setActor("salesperson");
action.setOwnerTable("customer_info");
BusinessActionEffectDesign effect = new BusinessActionEffectDesign();
effect.setType("INSERT_ROW");
effect.setTargetTable("opportunity");
action.setEffects(Arrays.asList(effect));
GenTableColumn salespersonId = column(
"salesperson_id", "salespersonId", "Salesperson", "input", "");
salespersonId.setIsRequired("1");
GenTable opportunity = table("opportunity", salespersonId);
BusinessActionRenderSupport.enrich(action, Arrays.asList(opportunity));
assertEquals("${current_user.id}", effect.getValues().get("salesperson_id"));
assertEquals(Arrays.asList("salesperson_id"), action.getCurrentUserFields());
assertEquals(Arrays.asList(), fields(action));
}
@Test
public void infersTargetFieldsFromEffectsAndEveryRuleValueInRequestOrder()
{

View File

@@ -140,6 +140,62 @@ public class TypedBusinessActionCompilerTest
assertTrue(TypedBusinessActionCompiler.compile(project, borrowRecord).getActions().isEmpty());
}
@Test
public void rendersMissingRequiredCreateFieldsAsTypedActionInputs()
{
GenTable customer = table("customer_info", "CustomerInfo", "Customer",
column("id", "Long", "id", true, "Customer ID"),
column("name", "String", "name", false, "Customer name"));
GenTable opportunity = table("opportunity", "Opportunity", "Opportunity",
column("id", "Long", "id", true, "Opportunity ID"),
requiredColumn("customer_id", "Long", "customerId", "Customer"),
requiredColumn("opportunity_name", "String", "opportunityName", "Opportunity name"),
requiredColumn("amount", "BigDecimal", "amount", "Amount"),
requiredColumn("status", "String", "status", "Status"),
requiredColumn("salesperson_id", "Long", "salespersonId", "Salesperson ID"),
requiredColumn("create_time", "Date", "createTime", "Create time"));
GenProject project = new GenProject();
project.setPackageName("com.example.crm");
project.setTables(Arrays.asList(customer, opportunity));
project.setFlowConfig(JSON.parseObject("{"
+ "\"code\":\"opportunity_flow\",\"name\":\"Opportunity flow\","
+ "\"mainTable\":\"opportunity\",\"statusField\":\"status\","
+ "\"states\":[{\"code\":\"new\",\"label\":\"New\"}],"
+ "\"actions\":[{\"code\":\"create_opportunity\",\"name\":\"Create opportunity\","
+ "\"actor\":\"salesperson\","
+ "\"ownerTable\":\"customer_info\",\"method\":\"POST\","
+ "\"path\":\"/customer-info/create-opportunity\",\"effects\":[{"
+ "\"type\":\"create_record\",\"table\":\"opportunity\","
+ "\"values\":{\"customer_id\":\"record.id\",\"status\":\"new\"}}]}]}",
SimpleFlowConfig.class));
TypedBusinessActionRenderModel model = TypedBusinessActionCompiler.compile(project, customer);
VelocityContext context = VelocityUtils.prepareContext(customer);
context.put("typedBusinessActionModel", model);
context.put("typedBusinessActions", model.getActions());
context.put("typedBusinessDependencies", model.getDependencies());
context.put("hasTypedBusinessActions", !model.getActions().isEmpty());
context.put("typedBusinessUsesBigDecimal", model.isUsesBigDecimal());
context.put("typedBusinessUsesDate", model.isUsesDate());
VelocityInitializer.initVelocity();
String implementation = render("qing/serviceImpl.java.vm", context);
String service = render("qing/service.java.vm", context);
String controller = render("qing/controller.java.vm", context);
assertTrue(implementation.contains("opportunity.setOpportunityName(inputOpportunityName);"));
assertTrue(implementation.contains("opportunity.setAmount(inputAmount);"));
assertTrue(implementation.contains("opportunity.setSalespersonId(currentUserId);"));
assertTrue(implementation.contains("opportunity.setCreateTime(new Date());"));
assertTrue(service.contains("void create_opportunity(Long id, Long currentUserId, "
+ "String inputOpportunityName, BigDecimal inputAmount);"));
assertTrue(controller.contains("Missing business action parameter: opportunity_name"));
assertTrue(controller.contains("String inputOpportunityName = String.valueOf("));
assertTrue(controller.contains("BigDecimal inputAmount = new BigDecimal(String.valueOf("));
assertTrue(controller.contains("customerInfoService.create_opportunity(id, currentUser.getUserId(), "
+ "inputOpportunityName, inputAmount);"));
}
@Test
public void rendersJavaSafeMethodNameForKeywordActionCode()
{

View File

@@ -0,0 +1,135 @@
-- Align managed flow/database Prompts with deterministic create_record action inputs.
-- Idempotent: each Prompt is versioned only when its current release lacks the marker.
DELIMITER $$
DROP PROCEDURE IF EXISTS upgrade_create_record_action_input_prompts_20260715$$
CREATE PROCEDURE upgrade_create_record_action_input_prompts_20260715()
BEGIN
DECLARE v_done INT DEFAULT 0;
DECLARE v_template_id BIGINT;
DECLARE v_current_version_id BIGINT;
DECLARE v_new_version_id BIGINT;
DECLARE v_version_no INT;
DECLARE v_version_label VARCHAR(50);
DECLARE v_generate_type VARCHAR(50);
DECLARE v_prompt_code VARCHAR(100);
DECLARE v_system_prompt LONGTEXT;
DECLARE v_user_prompt_contract VARCHAR(255);
DECLARE v_user_prompt_template LONGTEXT;
DECLARE v_target_system_prompt LONGTEXT;
DECLARE v_target_user_prompt LONGTEXT;
DECLARE v_provider_code VARCHAR(50);
DECLARE v_model VARCHAR(100);
DECLARE v_pipeline_release_code VARCHAR(100);
DECLARE v_content_hash CHAR(64);
DECLARE v_marker VARCHAR(100);
DECLARE v_rule LONGTEXT;
DECLARE prompt_cursor CURSOR FOR
SELECT t.prompt_template_id, t.current_version_id, t.generate_type, t.prompt_code,
v.system_prompt, v.user_prompt_contract, v.user_prompt_template,
v.provider_code, v.model, v.pipeline_release_code
FROM factory_prompt_template t
JOIN factory_prompt_version v ON v.prompt_version_id = t.current_version_id
WHERE t.prompt_code IN ('front.flow_config', 'front.database')
ORDER BY t.prompt_template_id;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1;
START TRANSACTION;
OPEN prompt_cursor;
prompt_loop: LOOP
FETCH prompt_cursor INTO v_template_id, v_current_version_id, v_generate_type, v_prompt_code,
v_system_prompt, v_user_prompt_contract, v_user_prompt_template,
v_provider_code, v_model, v_pipeline_release_code;
IF v_done = 1 THEN
LEAVE prompt_loop;
END IF;
SET v_target_system_prompt = v_system_prompt;
SET v_target_user_prompt = v_user_prompt_template;
IF v_prompt_code = 'front.flow_config' THEN
SET v_marker = 'CREATE_RECORD_ACTION_INPUT_V1';
SET v_rule = CONCAT(
'13. CREATE_RECORD_ACTION_INPUT_V1: Rule 7 coverage applies to values derivable ',
'from currentUserId, now, ownerTable record fields, or constants before the database ',
'schema is finalized. Meaningful business fields that must be entered when the action ',
'runs may be omitted from values; after schema finalization every omitted non-generated ',
'required column becomes a required typed action input. Never invent record.x unless x ',
'is a real ownerTable field.');
IF LOCATE(v_marker, v_system_prompt) > 0 THEN
ITERATE prompt_loop;
END IF;
SET v_target_system_prompt = CONCAT(v_system_prompt, CHAR(10), v_rule);
ELSE
SET v_marker = 'CREATE_RECORD_NULLABILITY_V1';
SET v_rule = CONCAT(
'CREATE_RECORD_NULLABILITY_V1: Required create_record target columns omitted from ',
'flowConfig values are intentional typed action inputs; keep meaningful columns required ',
'instead of weakening them to nullable. When a create_record value maps record.source_field ',
'into a target column with isRequired=1, the ownerTable source_field must also use ',
'isRequired=1 so the generated insert cannot propagate null.');
IF LOCATE(v_marker, v_user_prompt_template) > 0 THEN
ITERATE prompt_loop;
END IF;
SET v_target_user_prompt = CONCAT(v_user_prompt_template, CHAR(10), v_rule);
END IF;
SELECT COALESCE(MAX(version_no), 0) + 1
INTO v_version_no
FROM factory_prompt_version
WHERE prompt_template_id = v_template_id
FOR UPDATE;
SET v_version_label = CONCAT('V', v_version_no);
SET v_content_hash = SHA2(CONCAT(
'{"generateType":', JSON_QUOTE(v_generate_type),
',"promptCode":', JSON_QUOTE(v_prompt_code),
',"systemPrompt":', JSON_QUOTE(v_target_system_prompt),
',"userPromptContract":', JSON_QUOTE(v_user_prompt_contract),
',"userPromptTemplate":', JSON_QUOTE(v_target_user_prompt),
',"version":', JSON_QUOTE(v_version_label), '}'), 256);
INSERT INTO factory_prompt_version
(prompt_template_id, version_no, version_label, system_prompt,
user_prompt_contract, user_prompt_template, provider_code, model,
content_hash, status, parent_version_id, pipeline_release_code,
published_by, published_at, create_by, create_time, remark)
VALUES
(v_template_id, v_version_no, v_version_label, v_target_system_prompt,
v_user_prompt_contract, v_target_user_prompt, v_provider_code, v_model,
v_content_hash, 'DRAFT', v_current_version_id, v_pipeline_release_code,
'', NULL, 'system-upgrade', NOW(),
'Align create_record required fields with typed action inputs');
SET v_new_version_id = LAST_INSERT_ID();
UPDATE factory_prompt_version
SET status = 'RETIRED'
WHERE prompt_template_id = v_template_id
AND status = 'PUBLISHED'
AND prompt_version_id <> v_new_version_id;
UPDATE factory_prompt_version
SET status = 'PUBLISHED', published_by = 'system-upgrade', published_at = NOW()
WHERE prompt_version_id = v_new_version_id AND status = 'DRAFT';
UPDATE factory_prompt_template
SET current_version_id = v_new_version_id,
update_by = 'system-upgrade', update_time = NOW()
WHERE prompt_template_id = v_template_id;
END LOOP;
CLOSE prompt_cursor;
COMMIT;
END$$
CALL upgrade_create_record_action_input_prompts_20260715()$$
DROP PROCEDURE upgrade_create_record_action_input_prompts_20260715$$
DELIMITER ;