Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3082-cli-seed-generate.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `wheels seed --generate` now actually creates rows and reports honest success/failure. The CLI seed bridge's generate loop iterated `$classData().properties` (a struct keyed by property name) as if it were an array of property structs — so `prop.name` threw `there is no property with name [NAME] found in [string]`, every model errored, zero rows were created, yet the run still returned `success=true` and the CLI printed "Seeding completed." with exit 0. The generate path is now a dedicated, unit-tested `wheels.Seeder.generateSeeds(models, count)` method that iterates the property struct correctly and forces overall `success=false` when any model fails or no rows are created, so the CLI surfaces a non-zero exit instead of silently lying (the #2987 honesty fix had only covered convention mode) (#3082)
234 changes: 234 additions & 0 deletions vendor/wheels/Seeder.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,240 @@ component output="false" extends="wheels.Global" {
return local.result;
}

/**
* Generate fake records for one or more models — the legacy
* `wheels seed --generate` path. Unlike convention seeding this does not
* use seed files; it introspects each model's persisted properties and
* inserts `count` rows of plausible test data per model.
*
* Honesty contract (issue #3082): a model that throws, or whose generated
* rows do not all save, is recorded as a failed entry AND forces overall
* success=false. The previous CLI-view implementation iterated the
* $classData().properties STRUCT as if it were an array of property structs
* — so `prop.name` threw "there is no property with name [NAME] found in
* [string]" — created zero rows, yet still returned success=true and the
* CLI printed "Seeding completed." with exit 0.
*
* @models Comma-delimited list of model names. When blank, every *.cfc under
* /app/models (excluding _-prefixed files) is used.
* @count Number of rows to generate per model.
*/
public struct function generateSeeds(string models = "", numeric count = 10) {
var result = {
success = false,
mode = "generate",
seeded = [],
totalCreated = 0,
totalFailed = 0,
message = ""
};

var modelList = $resolveGenerateModels(arguments.models);

if (!ArrayLen(modelList)) {
result.message = "No models found to generate seed data for. Pass models=... or add models under /app/models.";
return result;
}

for (var modelName in modelList) {
try {
var modelInstance = model(modelName);
// $classData().properties is a STRUCT keyed by property name —
// each value is the property's metadata struct. Iterate the keys.
var properties = modelInstance.$classData().properties;
var seededCount = 0;

for (var i = 1; i <= arguments.count; i++) {
var record = {};
for (var propName in properties) {
if (propName != "id" && !ListFindNoCase("createdAt,updatedAt,deletedAt", propName)) {
var propType = StructKeyExists(properties[propName], "type") ? properties[propName].type : "string";
record[propName] = $generateTestData(propName, propType, i);
}
}
var newRecord = modelInstance.new(record);
if (newRecord.save()) {
seededCount++;
}
}

var entrySuccess = (seededCount == arguments.count);
ArrayAppend(result.seeded, {
model = modelName,
count = seededCount,
success = entrySuccess
});
result.totalCreated += seededCount;
if (!entrySuccess) {
result.totalFailed++;
}
} catch (any modelError) {
ArrayAppend(result.seeded, {
model = modelName,
count = 0,
success = false,
error = modelError.message
});
result.totalFailed++;
}
}

result.success = (result.totalFailed == 0 && result.totalCreated > 0);
if (result.success) {
result.message = "Database seeding completed. Created #result.totalCreated# records across #ArrayLen(result.seeded)# #ArrayLen(result.seeded) == 1 ? 'model' : 'models'#.";
} else {
result.message = "Database seeding failed. Created #result.totalCreated# records; #result.totalFailed# of #ArrayLen(result.seeded)# #result.totalFailed == 1 ? 'model' : 'models'# failed (#$failedGenerateSummary(result.seeded)#).";
}
return result;
}

/**
* Internal function. Resolves the model list for generateSeeds(): an
* explicit comma-delimited list when provided (blank entries trimmed away),
* otherwise every *.cfc model file under /app/models.
*/
public array function $resolveGenerateModels(string models = "") {
var list = [];
if (Len(Trim(arguments.models))) {
for (var name in ListToArray(arguments.models)) {
if (Len(Trim(name))) {
ArrayAppend(list, Trim(name));
}
}
return list;
}
var modelPath = ExpandPath("/app/models");
if (DirectoryExists(modelPath)) {
var modelFiles = DirectoryList(modelPath, false, "name", "*.cfc");
for (var file in modelFiles) {
if (Left(file, 1) != "_") {
ArrayAppend(list, ListFirst(file, "."));
}
}
}
return list;
}

/**
* Internal function. Builds a "model: reason" list for every failed entry
* recorded by generateSeeds(), used in its failure message.
*/
public string function $failedGenerateSummary(required array seeded) {
var parts = [];
for (var entry in arguments.seeded) {
if (!entry.success) {
var reason = StructKeyExists(entry, "error") ? entry.error : "only #entry.count# of the requested rows saved";
ArrayAppend(parts, "#entry.model#: #reason#");
}
}
return ArrayToList(parts, "; ");
}

/**
* Internal function. Produces a plausible fake value for a property based on
* its name and type — used by generateSeeds().
*/
public any function $generateTestData(required string propertyName, string propertyType = "string", numeric index = 1) {
local.name = LCase(arguments.propertyName);

// Email fields
if (FindNoCase("email", local.name)) {
return "test#arguments.index#@example.com";
}

// Name fields
if (FindNoCase("firstname", local.name) || local.name == "fname") {
local.firstNames = ["John", "Jane", "Bob", "Alice", "Charlie", "Diana", "Edward", "Fiona", "George", "Helen"];
return local.firstNames[(arguments.index - 1) mod ArrayLen(local.firstNames) + 1];
}

if (FindNoCase("lastname", local.name) || local.name == "lname") {
local.lastNames = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez"];
return local.lastNames[(arguments.index - 1) mod ArrayLen(local.lastNames) + 1];
}

if (local.name == "name" || FindNoCase("username", local.name)) {
return "TestUser#arguments.index#";
}

// Phone fields
if (FindNoCase("phone", local.name) || FindNoCase("mobile", local.name)) {
return "555-#NumberFormat(1000 + arguments.index, '0000')#";
}

// Address fields
if (FindNoCase("address", local.name) || FindNoCase("street", local.name)) {
return "#arguments.index# Test Street";
}

if (FindNoCase("city", local.name)) {
local.cities = ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "Philadelphia", "San Antonio", "San Diego"];
return local.cities[(arguments.index - 1) mod ArrayLen(local.cities) + 1];
}

if (FindNoCase("state", local.name) || FindNoCase("province", local.name)) {
local.states = ["CA", "TX", "FL", "NY", "PA", "IL", "OH", "GA"];
return local.states[(arguments.index - 1) mod ArrayLen(local.states) + 1];
}

if (FindNoCase("zip", local.name) || FindNoCase("postal", local.name)) {
return NumberFormat(10000 + arguments.index, "00000");
}

// URL fields
if (FindNoCase("url", local.name) || FindNoCase("website", local.name)) {
return "https://example#arguments.index#.com";
}

// Password fields
if (FindNoCase("password", local.name)) {
return "TestPass#arguments.index#!";
}

// Boolean fields
if (arguments.propertyType == "boolean" || FindNoCase("active", local.name) || FindNoCase("enabled", local.name) || FindNoCase("published", local.name)) {
return (arguments.index mod 2) == 1;
}

// Numeric fields
if (arguments.propertyType == "integer" || arguments.propertyType == "numeric") {
if (FindNoCase("age", local.name)) {
return 20 + (arguments.index mod 50);
}
if (FindNoCase("price", local.name) || FindNoCase("cost", local.name) || FindNoCase("amount", local.name)) {
return (arguments.index * 10) + 0.99;
}
if (FindNoCase("quantity", local.name) || FindNoCase("count", local.name)) {
return arguments.index * 5;
}
return arguments.index;
}

// Date fields
if (arguments.propertyType == "date" || arguments.propertyType == "datetime" || FindNoCase("date", local.name) || FindNoCase("birthday", local.name) || FindNoCase("dob", local.name)) {
return DateAdd("d", -arguments.index, Now());
}

// Text/description fields
if (arguments.propertyType == "text" || FindNoCase("description", local.name) || FindNoCase("content", local.name) || FindNoCase("body", local.name)) {
return "This is test content #arguments.index#. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
}

// Title fields
if (FindNoCase("title", local.name) || FindNoCase("subject", local.name)) {
return "Test Title #arguments.index#";
}

// Status fields
if (FindNoCase("status", local.name)) {
local.statuses = ["pending", "active", "completed", "cancelled"];
return local.statuses[(arguments.index - 1) mod ArrayLen(local.statuses) + 1];
}

// Default string value
return "#arguments.propertyName# Test #arguments.index#";
}

/**
* Internal function. Builds a "model: first error message" list for every
* failed entry recorded in this run, used in the runSeeds() failure message.
Expand Down
Loading
Loading