Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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)
245 changes: 245 additions & 0 deletions vendor/wheels/Seeder.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,251 @@ 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 and the framework's
* parent Model.cfc base class) 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,
// Generate mode never skips rows, but the CLI bridge contract
// requires the key: Module.cfc::runSeed() prints
// `#result.totalSkipped# skipped` whenever totalCreated exists.
totalSkipped = 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 except _-prefixed
* files and the framework's parent Model.cfc base class.
*/
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) {
// Skip the framework's parent Model.cfc — every scaffolded app
// ships it as the base class for its models, it has no backing
// table, and model("Model") throws Wheels.TableNotFound. Same
// exclusion as the CLI's model enumeration (Analysis.cfc and
// Module.cfc both skip it).
if (Left(file, 1) != "_" && file != "Model.cfc") {
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