diff --git a/vendor/wheels/model/associations.cfc b/vendor/wheels/model/associations.cfc index 6936babfd..264fef2c2 100644 --- a/vendor/wheels/model/associations.cfc +++ b/vendor/wheels/model/associations.cfc @@ -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); @@ -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); @@ -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); @@ -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 { @@ -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 diff --git a/vendor/wheels/model/onmissingmethod.cfc b/vendor/wheels/model/onmissingmethod.cfc index aa3d064b5..66f71a644 100644 --- a/vendor/wheels/model/onmissingmethod.cfc +++ b/vendor/wheels/model/onmissingmethod.cfc @@ -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#)"; } @@ -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; @@ -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#)"; } @@ -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; diff --git a/vendor/wheels/model/sql.cfc b/vendor/wheels/model/sql.cfc index c552b5142..4d78914d0 100644 --- a/vendor/wheels/model/sql.cfc +++ b/vendor/wheels/model/sql.cfc @@ -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); @@ -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"); } diff --git a/vendor/wheels/tests/_assets/models/PolyArticle.cfc b/vendor/wheels/tests/_assets/models/PolyArticle.cfc new file mode 100644 index 000000000..bf94a3c86 --- /dev/null +++ b/vendor/wheels/tests/_assets/models/PolyArticle.cfc @@ -0,0 +1,8 @@ +component extends="Model" { + + function config() { + table("c_o_r_e_polyarticles"); + hasMany(name="polyComments", modelName="PolyComment", as="commentable"); + } + +} diff --git a/vendor/wheels/tests/_assets/models/PolyComment.cfc b/vendor/wheels/tests/_assets/models/PolyComment.cfc new file mode 100644 index 000000000..7d5448999 --- /dev/null +++ b/vendor/wheels/tests/_assets/models/PolyComment.cfc @@ -0,0 +1,8 @@ +component extends="Model" { + + function config() { + table("c_o_r_e_polycomments"); + belongsTo(name="commentable", polymorphic=true); + } + +} diff --git a/vendor/wheels/tests/_assets/models/PolyPhoto.cfc b/vendor/wheels/tests/_assets/models/PolyPhoto.cfc new file mode 100644 index 000000000..fc84f9c7d --- /dev/null +++ b/vendor/wheels/tests/_assets/models/PolyPhoto.cfc @@ -0,0 +1,8 @@ +component extends="Model" { + + function config() { + table("c_o_r_e_polyphotos"); + hasMany(name="polyComments", modelName="PolyComment", as="commentable"); + } + +} diff --git a/vendor/wheels/tests/populate.cfm b/vendor/wheels/tests/populate.cfm index 3779d7e0b..eecdc35e2 100644 --- a/vendor/wheels/tests/populate.cfm +++ b/vendor/wheels/tests/populate.cfm @@ -96,7 +96,7 @@ - + @@ -350,6 +350,42 @@ CREATE TABLE c_o_r_e_bulkitems ) #local.storageEngine# + + +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# + + + +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# + + + +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# + + CREATE VIEW c_o_r_e_userphotos AS @@ -583,3 +619,28 @@ FROM c_o_r_e_users u INNER JOIN c_o_r_e_galleries g ON u.id = g.userid + + + + + + + + + +INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES ('Comment on article 1', #local.polyArticle1.id#, 'PolyArticle') + + +INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES ('Another comment on article 1', #local.polyArticle1.id#, 'PolyArticle') + + +INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES ('Comment on article 2', #local.polyArticle2.id#, 'PolyArticle') + + + + +INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES ('Comment on photo 1', #local.polyPhoto1.id#, 'PolyPhoto') + + +INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES ('Comment on photo 2', #local.polyPhoto2.id#, 'PolyPhoto') + diff --git a/vendor/wheels/tests/specs/model/polymorphicAssociationsSpec.cfc b/vendor/wheels/tests/specs/model/polymorphicAssociationsSpec.cfc new file mode 100644 index 000000000..9f3d9d9ff --- /dev/null +++ b/vendor/wheels/tests/specs/model/polymorphicAssociationsSpec.cfc @@ -0,0 +1,146 @@ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo + + describe("Polymorphic belongsTo", () => { + + it("stores polymorphic metadata in association struct", () => { + var classData = g.model("polyComment").$classData(); + var assoc = classData.associations.commentable; + + expect(assoc.type).toBe("belongsTo"); + expect(assoc.polymorphic).toBeTrue(); + expect(assoc.foreignKey).toBe("commentableid"); + expect(assoc.foreignType).toBe("commentabletype"); + // modelName should be empty — resolved at runtime. + expect(assoc.modelName).toBe(""); + }) + + it("resolves correct model type dynamically", () => { + var comment = g.model("polyComment").findOne(where="commentabletype = 'PolyArticle'"); + var parent = comment.commentable(); + + expect(IsObject(parent)).toBeTrue(); + expect(StructKeyExists(parent, "title")).toBeTrue(); + }) + + it("resolves different model types for different rows", () => { + var articleComment = g.model("polyComment").findOne(where="commentabletype = 'PolyArticle'"); + var photoComment = g.model("polyComment").findOne(where="commentabletype = 'PolyPhoto'"); + + var article = articleComment.commentable(); + var photo = photoComment.commentable(); + + expect(IsObject(article)).toBeTrue(); + expect(IsObject(photo)).toBeTrue(); + // They should come from different models. + expect(StructKeyExists(article, "title")).toBeTrue(); + expect(StructKeyExists(photo, "url")).toBeTrue(); + }) + + it("hasCommentable returns true when parent exists", () => { + var comment = g.model("polyComment").findOne(where="commentabletype = 'PolyArticle'"); + expect(comment.hasCommentable()).toBeTrue(); + }) + + it("hasCommentable returns false when foreign key is empty", () => { + var comment = g.model("polyComment").new(body="orphan", commentableid="", commentabletype=""); + expect(comment.hasCommentable()).toBeFalse(); + }) + + it("throws on include with polymorphic belongsTo", () => { + expect(function() { + g.model("polyComment").findAll(include="commentable"); + }).toThrow("Wheels.PolymorphicIncludeNotSupported"); + }) + + }) + + describe("Polymorphic hasMany", () => { + + it("stores polymorphic metadata with as in association struct", () => { + var classData = g.model("polyArticle").$classData(); + var assoc = classData.associations.polyComments; + + expect(assoc.type).toBe("hasMany"); + expect(assoc.as).toBe("commentable"); + expect(assoc.foreignKey).toBe("commentableid"); + expect(assoc.foreignType).toBe("commentabletype"); + }) + + it("returns only comments for the correct parent type", () => { + var article = g.model("polyArticle").findOne(where="title = 'First Article'"); + var comments = article.polyComments(); + + expect(comments.recordCount).toBeGTE(2); + // All comments should belong to this article. + var loop_ok = true; + for (var row = 1; row <= comments.recordCount; row++) { + if (comments.commentableid[row] != article.id || comments.commentabletype[row] != "PolyArticle") { + loop_ok = false; + } + } + expect(loop_ok).toBeTrue(); + }) + + it("returns different comments for different parent types", () => { + var article = g.model("polyArticle").findOne(where="title = 'First Article'"); + var photo = g.model("polyPhoto").findOne(where="url = 'http://example.com/photo1.jpg'"); + + var articleComments = article.polyComments(); + var photoComments = photo.polyComments(); + + // Article 1 has 2 comments, Photo 1 has 1 comment. + expect(articleComments.recordCount).toBe(2); + expect(photoComments.recordCount).toBe(1); + }) + + it("polyCommentCount returns correct count", () => { + var article = g.model("polyArticle").findOne(where="title = 'First Article'"); + expect(article.polyCommentCount()).toBe(2); + }) + + it("hasPolyComments returns true when children exist", () => { + var article = g.model("polyArticle").findOne(where="title = 'First Article'"); + expect(article.hasPolyComments()).toBeTrue(); + }) + + it("works with include on the inverse side", () => { + var articles = g.model("polyArticle").findAll(include="polyComments", where="c_o_r_e_polyarticles.title = 'First Article'", returnAs="query"); + expect(articles.recordCount).toBeGTE(1); + }) + + }) + + describe("Polymorphic hasOne", () => { + + it("stores polymorphic metadata with as in association struct", () => { + // We can test the metadata by checking the PolyPhoto model structure. + var classData = g.model("polyPhoto").$classData(); + var assoc = classData.associations.polyComments; + + expect(assoc.as).toBe("commentable"); + expect(assoc.foreignType).toBe("commentabletype"); + }) + + }) + + describe("Polymorphic foreign key conventions", () => { + + it("defaults foreignKey to {name}Id for polymorphic belongsTo", () => { + var classData = g.model("polyComment").$classData(); + expect(classData.associations.commentable.foreignKey).toBe("commentableid"); + }) + + it("defaults foreignKey to {as}Id for hasMany with as", () => { + var classData = g.model("polyArticle").$classData(); + expect(classData.associations.polyComments.foreignKey).toBe("commentableid"); + }) + + }) + + } + +}