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
46 changes: 41 additions & 5 deletions vendor/wheels/model/associations.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,23 @@ component {
string modelName = "",
string foreignKey = "",
string joinKey = "",
string joinType
string joinType,
boolean polymorphic = false
) {
$args(name = "belongsTo", args = arguments);
arguments.type = "belongsTo";

// Polymorphic belongsTo: the name is the interface name (e.g. "commentable").
// foreignKey defaults to {name}Id, and we add a foreignType column {name}Type.
if (arguments.polymorphic) {
if (!Len(arguments.foreignKey)) {
arguments.foreignKey = "#arguments.name#id";
}
arguments.foreignType = "#arguments.name#type";
// Don't infer modelName — it's resolved at runtime from the type column.
arguments.modelName = "";
}

// The dynamic shortcut methods to add to this class (e.g. "post" , "hasPost").
arguments.methods = "";
arguments.methods = ListAppend(arguments.methods, arguments.name);
Expand Down Expand Up @@ -53,13 +65,23 @@ component {
string joinType,
string dependent,
string shortcut = "",
string through = "#singularize(arguments.shortcut)#,#arguments.name#"
string through = "#singularize(arguments.shortcut)#,#arguments.name#",
string as = ""
) {
$args(name = "hasMany", args = arguments);
local.singularizedName = capitalize(singularize(arguments.name));
local.capitalizedName = capitalize(arguments.name);
arguments.type = "hasMany";

// Polymorphic hasMany: `as` is the polymorphic interface name on the child side.
// foreignKey defaults to {as}Id, and foreignType is {as}Type.
if (Len(arguments.as)) {
if (!Len(arguments.foreignKey)) {
arguments.foreignKey = "#arguments.as#id";
}
arguments.foreignType = "#arguments.as#type";
}

// The dynamic shortcut methods to add to this class (e.g. "comment", "commentCount", "addComment" etc).
arguments.methods = "";
arguments.methods = ListAppend(arguments.methods, arguments.name);
Expand Down Expand Up @@ -96,12 +118,22 @@ component {
string foreignKey = "",
string joinKey = "",
string joinType,
string dependent
string dependent,
string as = ""
) {
$args(name = "hasOne", args = arguments);
local.capitalizedName = capitalize(arguments.name);
arguments.type = "hasOne";

// Polymorphic hasOne: `as` is the polymorphic interface name on the child side.
// foreignKey defaults to {as}Id, and foreignType is {as}Type.
if (Len(arguments.as)) {
if (!Len(arguments.foreignKey)) {
arguments.foreignKey = "#arguments.as#id";
}
arguments.foreignType = "#arguments.as#type";
}

// The dynamic shortcut methods to add to this class (e.g. "profile", "createProfile", "deleteProfile" etc).
arguments.methods = "";
arguments.methods = ListAppend(arguments.methods, arguments.name);
Expand Down Expand Up @@ -131,7 +163,11 @@ component {
arguments.nested.rejectIfBlank = "";

// Infer model name from association name unless developer specified it already.
if (!Len(arguments.modelName)) {
// Polymorphic belongsTo skips inference — the model is resolved at runtime from the type column.
if (
!Len(arguments.modelName)
&& !(StructKeyExists(arguments, "polymorphic") && arguments.polymorphic)
) {
if (arguments.type == "hasMany") {
arguments.modelName = singularize(local.associationName);
} else {
Expand All @@ -143,7 +179,7 @@ component {
arguments.pluralizedName = pluralize(local.associationName);

// Set a friendly label for the foreign key on belongsTo associations (e.g. 'userid' becomes 'User');
if (arguments.type == "belongsTo") {
if (arguments.type == "belongsTo" && !(StructKeyExists(arguments, "polymorphic") && arguments.polymorphic)) {
// Get the property name using the specified foreign key or the wheels convention of modelName + id;
if (Len(arguments.foreignKey)) {
local.propertyName = arguments.foreignKey; // custom foreign key column
Expand Down
61 changes: 61 additions & 0 deletions vendor/wheels/model/onmissingmethod.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -305,11 +305,57 @@ component {
arguments.missingMethodArguments.returnIncluded = false;
}
} else if (ListFindNoCase(variables.wheels.class.associations[local.key].methods, arguments.missingMethodName)) {
local.assoc = variables.wheels.class.associations[local.key];

// Polymorphic belongsTo: resolve model dynamically from the type column.
if (
StructKeyExists(local.assoc, "polymorphic")
&& local.assoc.polymorphic
&& local.assoc.type == "belongsTo"
) {
local.name = ReplaceNoCase(arguments.missingMethodName, local.key, "object");
local.foreignKeyProp = local.assoc.foreignKey;
local.foreignTypeProp = local.assoc.foreignType;

if (local.name == "object") {
// Read the type column to determine which model to query.
if (StructKeyExists(this, local.foreignTypeProp) && Len(this[local.foreignTypeProp])
&& StructKeyExists(this, local.foreignKeyProp) && Len(this[local.foreignKeyProp])) {
local.componentReference = model(this[local.foreignTypeProp]);
local.method = "findByKey";
arguments.missingMethodArguments.key = this[local.foreignKeyProp];
}
} else if (local.name == "hasObject") {
// Check if the foreign key is non-empty.
if (StructKeyExists(this, local.foreignKeyProp) && Len(this[local.foreignKeyProp])
&& StructKeyExists(this, local.foreignTypeProp) && Len(this[local.foreignTypeProp])) {
local.componentReference = model(this[local.foreignTypeProp]);
local.method = "exists";
arguments.missingMethodArguments.key = this[local.foreignKeyProp];
} else {
local.rv = false;
}
}

if (Len(local.method) && StructKeyExists(local, "componentReference")) {
local.rv = $invoke(
componentReference = local.componentReference,
method = local.method,
invokeArgs = arguments.missingMethodArguments
);
}
continue;
}

local.info = $expandedAssociations(include = local.key);
local.info = local.info[1];
local.componentReference = model(local.info.modelName);
local.isPolymorphic = StructKeyExists(local.info, "as") && Len(local.info.as) && StructKeyExists(local.info, "foreignType");
if (local.info.type == "hasOne") {
local.where = $keyWhereString(properties = local.info.foreignKey, keys = primaryKeys());
if (local.isPolymorphic) {
local.where = "(#local.where#) AND (#local.info.foreignType# = '#variables.wheels.class.modelName#')";
}
if (StructKeyExists(arguments.missingMethodArguments, "where") && Len(arguments.missingMethodArguments.where)) {
local.where = "(#local.where#) AND (#arguments.missingMethodArguments.where#)";
}
Expand All @@ -326,9 +372,15 @@ component {
} else if (local.name == "newObject") {
local.method = "new";
$setForeignKeyValues(missingMethodArguments = arguments.missingMethodArguments, keys = local.info.foreignKey);
if (local.isPolymorphic) {
arguments.missingMethodArguments[local.info.foreignType] = variables.wheels.class.modelName;
}
} else if (local.name == "createObject") {
local.method = "create";
$setForeignKeyValues(missingMethodArguments = arguments.missingMethodArguments, keys = local.info.foreignKey);
if (local.isPolymorphic) {
arguments.missingMethodArguments[local.info.foreignType] = variables.wheels.class.modelName;
}
} else if (local.name == "removeObject") {
local.method = "updateOne";
arguments.missingMethodArguments.where = local.where;
Expand Down Expand Up @@ -378,6 +430,9 @@ component {
} else {
local.where = $keyWhereString(properties = local.info.foreignKey, keys = primaryKeys());
}
if (local.isPolymorphic) {
local.where = "(#local.where#) AND (#local.info.foreignType# = '#variables.wheels.class.modelName#')";
}
if (StructKeyExists(arguments.missingMethodArguments, "where") && Len(arguments.missingMethodArguments.where)) {
local.where = "(#local.where#) AND (#arguments.missingMethodArguments.where#)";
}
Expand Down Expand Up @@ -496,9 +551,15 @@ component {
} else if (local.name == "newObject") {
local.method = "new";
$setForeignKeyValues(missingMethodArguments = arguments.missingMethodArguments, keys = local.info.foreignKey);
if (local.isPolymorphic) {
arguments.missingMethodArguments[local.info.foreignType] = variables.wheels.class.modelName;
}
} else if (local.name == "createObject") {
local.method = "create";
$setForeignKeyValues(missingMethodArguments = arguments.missingMethodArguments, keys = local.info.foreignKey);
if (local.isPolymorphic) {
arguments.missingMethodArguments[local.info.foreignType] = variables.wheels.class.modelName;
}
} else if (local.name == "objectCount") {
local.method = "count";
arguments.missingMethodArguments.where = local.where;
Expand Down
27 changes: 27 additions & 0 deletions vendor/wheels/model/sql.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -1159,6 +1159,18 @@ component {
);
}

// Polymorphic belongsTo cannot be eager-loaded via include — the target model varies per row.
if (
StructKeyExists(local.classAssociations[local.name], "polymorphic")
&& local.classAssociations[local.name].polymorphic
) {
Throw(
type = "Wheels.PolymorphicIncludeNotSupported",
message = "Cannot use `include` with the polymorphic belongsTo association `#local.name#`.",
extendedInfo = "Polymorphic belongsTo associations resolve the target model dynamically per row. Use the dynamic method (e.g. `obj.#local.name#()`) instead of `include`."
);
}

// create a reference to the associated class
local.associatedClass = model(local.classAssociations[local.name].modelName);

Expand Down Expand Up @@ -1251,6 +1263,21 @@ component {
);
}
}

// Polymorphic hasMany/hasOne with `as`: add type discriminator to JOIN ON clause.
if (
StructKeyExists(local.classAssociations[local.name], "as")
&& Len(local.classAssociations[local.name].as)
&& StructKeyExists(local.classAssociations[local.name], "foreignType")
) {
local.typeColumn = local.classAssociations[local.name].foreignType;
local.typeValue = local.class.$classData().modelName;
local.toAppend = ListAppend(
local.toAppend,
"#variables.wheels.class.adapter.$quoteIdentifier(local.tableName)#.#variables.wheels.class.adapter.$quoteIdentifier(local.typeColumn)# = '#local.typeValue#'"
);
}

local.classAssociations[local.name].join = local.join & Replace(local.toAppend, ",", " AND ", "all");
}

Expand Down
8 changes: 8 additions & 0 deletions vendor/wheels/tests/_assets/models/PolyArticle.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
component extends="Model" {

function config() {
table("c_o_r_e_polyarticles");
hasMany(name="polyComments", modelName="PolyComment", as="commentable");
}

}
8 changes: 8 additions & 0 deletions vendor/wheels/tests/_assets/models/PolyComment.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
component extends="Model" {

function config() {
table("c_o_r_e_polycomments");
belongsTo(name="commentable", polymorphic=true);
}

}
8 changes: 8 additions & 0 deletions vendor/wheels/tests/_assets/models/PolyPhoto.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
component extends="Model" {

function config() {
table("c_o_r_e_polyphotos");
hasMany(name="polyComments", modelName="PolyComment", as="commentable");
}

}
63 changes: 62 additions & 1 deletion vendor/wheels/tests/populate.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
</cfloop>

<!--- list of tables to delete --->
<cfset local.tables = "c_o_r_e_authors,c_o_r_e_cities,c_o_r_e_classifications,c_o_r_e_comments,c_o_r_e_galleries,c_o_r_e_photos,c_o_r_e_posts,c_o_r_e_profiles,c_o_r_e_shops,c_o_r_e_trucks,c_o_r_e_tags,c_o_r_e_users,c_o_r_e_collisiontests,c_o_r_e_combikeys,c_o_r_e_tblusers,c_o_r_e_sqltypes,c_o_r_e_CATEGORIES,c_o_r_e_bulkitems">
<cfset local.tables = "c_o_r_e_polycomments,c_o_r_e_polyarticles,c_o_r_e_polyphotos,c_o_r_e_authors,c_o_r_e_cities,c_o_r_e_classifications,c_o_r_e_comments,c_o_r_e_galleries,c_o_r_e_photos,c_o_r_e_posts,c_o_r_e_profiles,c_o_r_e_shops,c_o_r_e_trucks,c_o_r_e_tags,c_o_r_e_users,c_o_r_e_collisiontests,c_o_r_e_combikeys,c_o_r_e_tblusers,c_o_r_e_sqltypes,c_o_r_e_CATEGORIES,c_o_r_e_bulkitems">
<cfloop list="#local.tables#" index="local.i">
<cfif ListFindNoCase(local.tableList, local.i, Chr(7))>
<cftry>
Expand Down Expand Up @@ -350,6 +350,42 @@ CREATE TABLE c_o_r_e_bulkitems
) #local.storageEngine#
</cfquery>

<!--- polymorphic association test tables --->
<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
CREATE TABLE c_o_r_e_polyarticles
(
id #local.identityColumnType#
,title varchar(255) NOT NULL
,createdat #local.datetimeColumnType# NULL
,updatedat #local.datetimeColumnType# NULL
,PRIMARY KEY(id)
) #local.storageEngine#
</cfquery>

<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
CREATE TABLE c_o_r_e_polyphotos
(
id #local.identityColumnType#
,url varchar(255) NOT NULL
,createdat #local.datetimeColumnType# NULL
,updatedat #local.datetimeColumnType# NULL
,PRIMARY KEY(id)
) #local.storageEngine#
</cfquery>

<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
CREATE TABLE c_o_r_e_polycomments
(
id #local.identityColumnType#
,body #local.textColumnType# NOT NULL
,commentableid #local.intColumnType# NULL
,commentabletype varchar(255) NULL
,createdat #local.datetimeColumnType# NULL
,updatedat #local.datetimeColumnType# NULL
,PRIMARY KEY(id)
) #local.storageEngine#
</cfquery>

<!--- create views --->
<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
CREATE VIEW c_o_r_e_userphotos AS
Expand Down Expand Up @@ -583,3 +619,28 @@ FROM c_o_r_e_users u INNER JOIN c_o_r_e_galleries g ON u.id = g.userid
<!--- uppercase table --->
<cfset model("category").create(category_name = "Quick Brown Foxes")>
<cfset model("category").create(category_name = "Lazy Dogs")>

<!--- polymorphic association test data --->
<cfset local.polyArticle1 = model("polyArticle").create(title = "First Article")>
<cfset local.polyArticle2 = model("polyArticle").create(title = "Second Article")>
<cfset local.polyPhoto1 = model("polyPhoto").create(url = "http://example.com/photo1.jpg")>
<cfset local.polyPhoto2 = model("polyPhoto").create(url = "http://example.com/photo2.jpg")>

<!--- comments on articles --->
<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES ('Comment on article 1', #local.polyArticle1.id#, 'PolyArticle')
</cfquery>
<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES ('Another comment on article 1', #local.polyArticle1.id#, 'PolyArticle')
</cfquery>
<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES ('Comment on article 2', #local.polyArticle2.id#, 'PolyArticle')
</cfquery>

<!--- comments on photos --->
<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES ('Comment on photo 1', #local.polyPhoto1.id#, 'PolyPhoto')
</cfquery>
<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES ('Comment on photo 2', #local.polyPhoto2.id#, 'PolyPhoto')
</cfquery>
Loading
Loading