From b6ca24b8abd1254592e385ef5ff1e5f371783422 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 12:05:15 -0600 Subject: [PATCH 1/7] test: define belongs-to-many pivot model behavior --- tests/resources/app/models/Post.cfc | 52 +++++++++++ tests/resources/app/models/PostTag.cfc | 18 ++++ ...8_11_102625_create_my_posts_tags_table.cfc | 31 +++++-- .../Relationships/BelongsToManySpec.cfc | 92 +++++++++++++++++++ 4 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 tests/resources/app/models/PostTag.cfc diff --git a/tests/resources/app/models/Post.cfc b/tests/resources/app/models/Post.cfc index b68781ef..eeb84f1d 100644 --- a/tests/resources/app/models/Post.cfc +++ b/tests/resources/app/models/Post.cfc @@ -45,6 +45,58 @@ component ); } + function tagsWithPivot() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ).withPivot( [ "context", "active" ] ); + } + + function tagsAsSubscriptions() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ) + .withPivot( "context" ) + .as( "subscription" ); + } + + function tagsWithCustomPivot() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ) + .using( "PostTag" ) + .withPivot( [ "context", "active" ] ); + } + + function activeTags() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ) + .withPivot( [ "context", "active" ] ) + .wherePivot( "active", true ) + .orderByPivot( "context" ); + } + + function timestampedTags() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ).withTimestamps( "created_date", "modified_date" ); + } + function comments() { return polymorphicHasMany( "Comment", "commentable" ); } diff --git a/tests/resources/app/models/PostTag.cfc b/tests/resources/app/models/PostTag.cfc new file mode 100644 index 00000000..9dfc462b --- /dev/null +++ b/tests/resources/app/models/PostTag.cfc @@ -0,0 +1,18 @@ +component + extends ="quick.models.Relationships.Pivot" + accessors="true" + readonly ="false" +{ + + property name="customPostPk" column="custom_post_pk"; + property name="tagId" column="tag_id"; + property name="context"; + property name="active" casts="BooleanCast@quick"; + property name="createdDate" column="created_date"; + property name="modifiedDate" column="modified_date"; + + function describe() { + return "#getContext()#:#getTagId()#"; + } + +} diff --git a/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc b/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc index ec8fca79..60694416 100755 --- a/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc @@ -4,6 +4,10 @@ component { schema.create( "my_posts_tags", function( t ) { t.unsignedInteger( "custom_post_pk" ); t.unsignedInteger( "tag_id" ); + t.string( "context" ).nullable(); + t.boolean( "active" ).default( false ); + t.timestamp( "created_date" ).nullable(); + t.timestamp( "modified_date" ).nullable(); t.primaryKey( [ "custom_post_pk", "tag_id" ] ); } ); @@ -11,25 +15,40 @@ component { .insert( [ { "custom_post_pk" : 1245, - "tag_id" : 1 + "tag_id" : 1, + "context" : "primary", + "active" : true }, { "custom_post_pk" : 1245, - "tag_id" : 2 + "tag_id" : 2, + "context" : "secondary", + "active" : false }, { "custom_post_pk" : 523526, - "tag_id" : 1 + "tag_id" : 1, + "context" : "archived", + "active" : false }, { "custom_post_pk" : 523526, - "tag_id" : 2 + "tag_id" : 2, + "context" : "review", + "active" : true }, { "custom_post_pk" : 523526, - "tag_id" : 3 + "tag_id" : 3, + "context" : "published", + "active" : true }, - { "custom_post_pk" : 321, "tag_id" : 2 } + { + "custom_post_pk" : 321, + "tag_id" : 2, + "context" : "legacy", + "active" : false + } ] ); } diff --git a/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc b/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc index e1801fde..2e472352 100644 --- a/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc @@ -19,6 +19,98 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( posts ).toBeArray(); expect( posts ).toHaveLength( 2 ); } ); + + it( "hydrates declared pivot columns on a pivot model", function() { + var post = getInstance( "Post" ).findOrFail( 1245 ); + var tag = post.getTagsWithPivot()[ 1 ]; + var pivot = tag.getPivot(); + + expect( pivot ).toBeInstanceOf( "quick.models.Relationships.Pivot" ); + expect( pivot.isLoaded() ).toBeTrue(); + expect( pivot.getCustom_post_pk() ).toBe( post.getPost_pk() ); + expect( pivot.getTag_id() ).toBe( tag.getId() ); + expect( pivot.getContext() ).toBe( "primary" ); + expect( pivot.getActive() ).toBeTrue(); + expect( pivot.getPivotParent() ).toBe( post ); + expect( pivot.getPivotRelated() ).toBe( tag ); + expect( pivot.getMemento() ).toInclude( { + "custom_post_pk" : 1245, + "tag_id" : 1, + "context" : "primary" + } ); + } ); + + it( "hydrates the correct pivot for every eagerly loaded parent", function() { + var posts = getInstance( "Post" ) + .with( "tagsWithPivot" ) + .whereIn( "post_pk", [ 1245, 523526 ] ) + .orderBy( "post_pk" ) + .get(); + + var firstPostTag = posts[ 1 ].getTagsWithPivot()[ 1 ]; + var secondPostTag = posts[ 2 ].getTagsWithPivot()[ 1 ]; + + expect( firstPostTag.getPivot().getCustom_post_pk() ).toBe( posts[ 1 ].getPost_pk() ); + expect( secondPostTag.getPivot().getCustom_post_pk() ).toBe( posts[ 2 ].getPost_pk() ); + expect( firstPostTag.getPivot().getContext() ).notToBe( secondPostTag.getPivot().getContext() ); + } ); + + it( "can customize the pivot accessor", function() { + var tag = getInstance( "Post" ).findOrFail( 1245 ).getTagsAsSubscriptions()[ 1 ]; + + expect( tag.isRelationshipLoaded( "subscription" ) ).toBeTrue(); + expect( tag.getSubscription().getContext() ).toBe( "primary" ); + } ); + + it( "can hydrate a custom pivot model with casts and behavior", function() { + var pivot = getInstance( "Post" ).findOrFail( 1245 ).getTagsWithCustomPivot()[ 1 ].getPivot(); + + expect( pivot ).toBeInstanceOf( "app.models.PostTag" ); + expect( pivot.getActive() ).toBeBoolean().toBeTrue(); + expect( pivot.describe() ).toBe( "primary:1" ); + } ); + + it( "can constrain and order by pivot columns", function() { + var tags = getInstance( "Post" ).findOrFail( 523526 ).getActiveTags(); + + expect( tags ).toHaveLength( 2 ); + expect( tags[ 1 ].getPivot().getContext() ).toBe( "published" ); + expect( tags[ 2 ].getPivot().getContext() ).toBe( "review" ); + } ); + + it( "writes and updates additional pivot attributes", function() { + var post = getInstance( "Post" ).findOrFail( 1245 ); + + post.tagsWithPivot().attach( 3, { "context" : "new", "active" : true } ); + var attached = post.tagsWithPivot().findOrFail( 3 ); + expect( attached.getPivot().getContext() ).toBe( "new" ); + expect( attached.getPivot().getActive() ).toBeTrue(); + + post.tagsWithPivot() + .updateExistingPivot( + 3, + { + "context" : "updated", + "active" : false + } + ); + var updated = post.tagsWithPivot().findOrFail( 3 ); + expect( updated.getPivot().getContext() ).toBe( "updated" ); + expect( updated.getPivot().getActive() ).toBeFalse(); + } ); + + it( "maintains configured pivot timestamps", function() { + var post = getInstance( "Post" ).findOrFail( 321 ); + + post.timestampedTags().attach( 1 ); + var pivot = post + .timestampedTags() + .findOrFail( 1 ) + .getPivot(); + + expect( pivot.getCreated_date() ).notToBeNull(); + expect( pivot.getModified_date() ).notToBeNull(); + } ); } ); } From b52b2d2976840801a77ebe49e367fb435a80b552 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 12:14:58 -0600 Subject: [PATCH 2/7] feat: add belongs-to-many pivot models --- models/BaseEntity.cfc | 6 +- models/QuickBuilder.cfc | 38 ++ models/Relationships/BelongsToMany.cfc | 412 +++++++++++++++++- models/Relationships/Pivot.cfc | 66 +++ tests/resources/app/models/Post.cfc | 11 + .../Relationships/BelongsToManySpec.cfc | 65 ++- 6 files changed, 569 insertions(+), 29 deletions(-) create mode 100644 models/Relationships/Pivot.cfc diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 3184ccdb..01c19168 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -2602,10 +2602,14 @@ component accessors="true" { var relationshipName = variables._str.slice( arguments.missingMethodName, 4 ); - if ( !hasRelationship( relationshipName ) ) { + if ( !hasRelationship( relationshipName ) && !isRelationshipLoaded( relationshipName ) ) { return; } + if ( isRelationshipLoaded( relationshipName ) ) { + return retrieveRelationship( relationshipName ); + } + if ( !isRelationshipLoaded( relationshipName ) && variables._preventLazyLoading ) { variables._lazyLoadingViolationCallback( this, relationshipName ); } diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 3d46183a..7d6d8dc9 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -75,6 +75,11 @@ component accessors="true" transientCache="false" { property name="_asMemento" default="false"; property name="_asMementoSettings"; + /** + * Callbacks applied to each hydrated entity before return transformations. + */ + property name="_entityTransformers"; + /** * Used to quickly identify QueryBuilder instances * instead of resorting to `isInstanceOf` which is slow. @@ -94,6 +99,7 @@ component accessors="true" transientCache="false" { variables._asMemento = false; variables._asQuery = false; variables._withAliases = false; + variables._entityTransformers = []; param variables._preventLazyLoading = false; if ( !variables.keyExists( "_lazyLoadingViolationCallback" ) || isNull( variables._lazyLoadingViolationCallback ) ) { variables._lazyLoadingViolationCallback = ( entity, relationName ) => { @@ -109,6 +115,18 @@ component accessors="true" transientCache="false" { return this; } + /** + * Adds a callback that transforms each hydrated entity before it is returned. + * + * @transformer The callback accepting and returning an entity. + * + * @return quick.models.QuickBuilder + */ + public QuickBuilder function addEntityTransformer( required any transformer ) { + variables._entityTransformers.append( arguments.transformer ); + return this; + } + function onDIComplete() { variables.qb.setQuickBuilder( this ); variables.qb.setColumnFormatter( function( column ) { @@ -1489,6 +1507,16 @@ component accessors="true" transientCache="false" { * @return any */ private any function handleTransformations( entity ) { + if ( !variables._asQuery ) { + if ( isArray( arguments.entity ) ) { + arguments.entity = arguments.entity.map( function( item ) { + return applyEntityTransformers( arguments.item ); + } ); + } else if ( !isNull( arguments.entity ) ) { + arguments.entity = applyEntityTransformers( arguments.entity ); + } + } + if ( !variables._asMemento ) { return arguments.entity; } @@ -1502,6 +1530,15 @@ component accessors="true" transientCache="false" { } ); } + /** + * Applies all configured entity transformers to one hydrated entity. + */ + private any function applyEntityTransformers( required any entity ) { + return variables._entityTransformers.reduce( function( transformed, transformer ) { + return arguments.transformer( arguments.transformed ); + }, arguments.entity ); + } + /** * Activates the global scopes while checking for excluded global scopes. * @@ -1741,6 +1778,7 @@ component accessors="true" transientCache="false" { newBuilder.set_withAliases( this.get_withAliases() ); newBuilder.set_asMemento( this.get_asMemento() ); newBuilder.set_asMementoSettings( this.get_asMementoSettings() ); + newBuilder.set_entityTransformers( this.get_entityTransformers() ); return newBuilder; } diff --git a/models/Relationships/BelongsToMany.cfc b/models/Relationships/BelongsToMany.cfc index 6acb2d72..61f58cb8 100644 --- a/models/Relationships/BelongsToMany.cfc +++ b/models/Relationships/BelongsToMany.cfc @@ -57,6 +57,36 @@ component */ property name="tableSuffix" type="string"; + /** + * Additional pivot columns to hydrate on the pivot model. + */ + property name="pivotColumns" type="array"; + + /** + * The relationship name used to expose the hydrated pivot model. + */ + property name="pivotAccessor" type="string"; + + /** + * An optional custom Pivot entity mapping. + */ + property name="pivotEntity"; + + /** + * Internal aliases used to keep pivot columns separate from related columns. + */ + property name="pivotColumnAliases" type="struct"; + + /** + * Values applied to pivot writes and relationship constraints. + */ + property name="pivotValues" type="struct"; + + /** + * Configured created and modified timestamp columns for pivot writes. + */ + property name="pivotTimestampColumns" type="array"; + /** * Used to check for the type of relationship more quickly than using isInstanceOf. */ @@ -94,21 +124,32 @@ component required array relatedKeys, boolean withConstraints = true ) { - variables.table = arguments.table; - variables.parentKeys = arguments.parentKeys; - variables.foreignKeys = arguments.parentKeys; - variables.relatedKeys = arguments.relatedKeys; - variables.relatedPivotKeys = arguments.relatedPivotKeys; - variables.foreignPivotKeys = arguments.foreignPivotKeys; - variables.tablePrefix = ""; - - return super.init( + variables.table = arguments.table; + variables.parentKeys = arguments.parentKeys; + variables.foreignKeys = arguments.parentKeys; + variables.relatedKeys = arguments.relatedKeys; + variables.relatedPivotKeys = arguments.relatedPivotKeys; + variables.foreignPivotKeys = arguments.foreignPivotKeys; + variables.tablePrefix = ""; + variables.pivotColumns = []; + variables.pivotAccessor = "pivot"; + variables.pivotColumnAliases = {}; + variables.pivotValues = {}; + variables.pivotTimestampColumns = []; + + super.init( related = arguments.related, relationName = arguments.relationName, relationMethodName = arguments.relationMethodName, parent = arguments.parent, withConstraints = arguments.withConstraints ); + + variables.relationshipBuilder.addEntityTransformer( function( entity ) { + return hydratePivot( arguments.entity ); + } ); + + return this; } /** @@ -128,6 +169,7 @@ component */ public void function addConstraints() { performJoin(); + addPivotSelects(); addWhereConstraints(); } @@ -149,10 +191,7 @@ component } performJoin(); - variables.foreignPivotKeys.each( function( foreignPivotKey ) { - variables.relationshipBuilder.addSelect( listLast( variables.table, " " ) & "." & foreignPivotKey ); - variables.relationshipBuilder.appendVirtualAttribute( name = foreignPivotKey, excludeFromMemento = true ); - } ); + addPivotSelects(); variables.relationshipBuilder.where( function( q1 ) { allKeys.each( function( keys ) { @@ -239,10 +278,13 @@ component */ public struct function buildDictionary( required array results ) { return arguments.results.reduce( function( dict, result ) { + var pivot = structKeyExists( arguments.result, "isQuickEntity" ) + ? arguments.result.retrieveRelationship( variables.pivotAccessor ) + : {}; var key = variables.foreignPivotKeys .map( function( foreignPivotKey ) { - return structKeyExists( result, "isQuickEntity" ) ? result.retrieveAttribute( foreignPivotKey ) : result[ - foreignPivotKey + return structKeyExists( result, "isQuickEntity" ) ? pivot.retrieveAttribute( foreignPivotKey ) : result[ + variables.pivotColumnAliases[ foreignPivotKey ] ]; } ) .toList(); @@ -320,18 +362,324 @@ component } ); } + /** + * Includes additional intermediate-table columns on each related entity's Pivot model. + * Accepts a column name, comma-delimited list, or array. + * + * @columns The pivot columns to include. + * + * @return quick.models.Relationships.BelongsToMany + */ + public BelongsToMany function withPivot( required any columns ) { + var normalizedColumns = isArray( arguments.columns ) + ? arguments.columns + : listToArray( arguments.columns ); + + for ( var column in normalizedColumns ) { + if ( !variables.pivotColumns.findNoCase( column ) ) { + variables.pivotColumns.append( column ); + } + } + + addPivotSelects(); + return this; + } + + /** + * Uses a custom loaded-relationship name instead of `pivot`. + */ + public BelongsToMany function as( required string accessor ) { + if ( !len( trim( arguments.accessor ) ) ) { + throw( type = "QuickInvalidPivotAccessor", message = "A pivot accessor cannot be empty." ); + } + variables.pivotAccessor = arguments.accessor; + return this; + } + + /** + * Uses a custom Pivot entity mapping for hydrated intermediate rows. + */ + public BelongsToMany function using( required string pivotEntity ) { + variables.pivotEntity = arguments.pivotEntity; + return this; + } + + /** + * Includes and maintains timestamp columns on pivot writes. + */ + public BelongsToMany function withTimestamps( string createdAt = "created_at", string modifiedAt = "updated_at" ) { + variables.pivotTimestampColumns = [ + arguments.createdAt, + arguments.modifiedAt + ]; + return withPivot( variables.pivotTimestampColumns ); + } + + /** + * Adds a where constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivot( + required string column, + any operator, + any value, + string combinator = "and" + ) { + if ( !arguments.keyExists( "value" ) || isNull( arguments.value ) ) { + arguments.value = arguments.operator; + arguments.operator = "="; + } + variables.relationshipBuilder.where( + column = qualifyPivotColumn( arguments.column ), + operator = arguments.operator, + value = arguments.value, + combinator = arguments.combinator + ); + return this; + } + + /** + * Adds an or-where constraint using a qualified pivot column. + */ + public BelongsToMany function orWherePivot( + required string column, + any operator, + any value + ) { + arguments.combinator = "or"; + return wherePivot( argumentCollection = arguments ); + } + + /** + * Adds a where-in constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotIn( + required string column, + required any values, + string combinator = "and" + ) { + variables.relationshipBuilder.whereIn( + qualifyPivotColumn( arguments.column ), + arguments.values, + arguments.combinator + ); + return this; + } + + /** + * Adds a where-not-in constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotNotIn( + required string column, + required any values, + string combinator = "and" + ) { + variables.relationshipBuilder.whereNotIn( + qualifyPivotColumn( arguments.column ), + arguments.values, + arguments.combinator + ); + return this; + } + + /** + * Adds a where-between constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotBetween( + required string column, + required any start, + required any end, + string combinator = "and" + ) { + variables.relationshipBuilder.whereBetween( + qualifyPivotColumn( arguments.column ), + arguments.start, + arguments.end, + arguments.combinator + ); + return this; + } + + /** + * Adds a where-not-between constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotNotBetween( + required string column, + required any start, + required any end, + string combinator = "and" + ) { + variables.relationshipBuilder.whereNotBetween( + qualifyPivotColumn( arguments.column ), + arguments.start, + arguments.end, + arguments.combinator + ); + return this; + } + + /** + * Adds a where-null constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotNull( required string column, string combinator = "and" ) { + variables.relationshipBuilder.whereNull( qualifyPivotColumn( arguments.column ), arguments.combinator ); + return this; + } + + /** + * Adds a where-not-null constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotNotNull( required string column, string combinator = "and" ) { + variables.relationshipBuilder.whereNotNull( qualifyPivotColumn( arguments.column ), arguments.combinator ); + return this; + } + + /** + * Orders the related results using a qualified pivot column. + */ + public BelongsToMany function orderByPivot( required string column, string direction = "asc" ) { + variables.relationshipBuilder.orderBy( qualifyPivotColumn( arguments.column ), arguments.direction ); + return this; + } + + /** + * Orders the related results descending using a qualified pivot column. + */ + public BelongsToMany function orderByPivotDesc( required string column ) { + return orderByPivot( arguments.column, "desc" ); + } + + /** + * Constrains a pivot value and uses it as a default for pivot writes. + */ + public BelongsToMany function withPivotValue( required string column, required any value ) { + variables.pivotValues[ arguments.column ] = arguments.value; + withPivot( arguments.column ); + return wherePivot( arguments.column, arguments.value ); + } + + /** + * Adds any pivot columns not already present to the related select list. + */ + private void function addPivotSelects() { + var columns = duplicate( variables.foreignPivotKeys ) + .append( variables.relatedPivotKeys, true ) + .append( variables.pivotColumns, true ); + + for ( var column in columns ) { + if ( variables.pivotColumnAliases.keyExists( column ) ) { + continue; + } + + var aliasName = "__quick_pivot_#variables.pivotColumnAliases.count() + 1#"; + variables.pivotColumnAliases[ column ] = aliasName; + variables.relationshipBuilder.addSelect( "#qualifyPivotColumn( column )# AS #aliasName#" ); + variables.relationshipBuilder.appendVirtualAttribute( name = aliasName, excludeFromMemento = true ); + } + } + + /** + * Hydrates and assigns a Pivot model to a related entity. + */ + private any function hydratePivot( required any entity ) { + if ( !structKeyExists( arguments.entity, "isQuickEntity" ) ) { + return arguments.entity; + } + + var attributes = {}; + for ( var column in variables.pivotColumnAliases ) { + var value = arguments.entity.retrieveAttribute( variables.pivotColumnAliases[ column ] ); + attributes[ column ] = isNull( value ) ? javacast( "null", "" ) : value; + } + + var mapping = isNull( variables.pivotEntity ) ? "Pivot@quick" : variables.pivotEntity; + var pivot = variables.wirebox.getInstance( mapping ); + if ( !structKeyExists( pivot, "isPivot" ) ) { + throw( + type = "QuickInvalidPivotModel", + message = "The custom pivot model [#mapping#] must extend [quick.models.Relationships.Pivot]." + ); + } + + if ( !isNull( variables.pivotEntity ) ) { + for ( var attributeName in attributes ) { + if ( !pivot.hasAttribute( attributeName ) ) { + throw( + type = "QuickPivotAttributeNotFound", + message = "The pivot attribute [#attributeName#] is not declared on [#mapping#]." + ); + } + } + } + + pivot.configurePivot( + table = listFirst( variables.table, " " ), + keyNames = duplicate( variables.foreignPivotKeys ).append( variables.relatedPivotKeys, true ), + attributes = attributes, + parent = variables.parent, + related = arguments.entity + ); + arguments.entity.assignRelationship( variables.pivotAccessor, pivot ); + return arguments.entity; + } + + /** + * Qualifies a pivot column unless it is already qualified. + */ + private string function qualifyPivotColumn( required string column ) { + return find( ".", arguments.column ) ? arguments.column : "#listLast( variables.table, " " )#.#arguments.column#"; + } + /** * Associates one or more ids of the related entity to the parent entity. * - * @id The id or array of ids of the related entity. + * @id The id or array of ids of the related entity. + * @pivotAttributes Additional attributes for each inserted pivot row. * * @return quick.models.BaseEntity */ - public any function attach( required any id ) { - variables.newPivotStatement().insert( parseIdsForInsert( arguments.id ) ); + public any function attach( required any id, struct pivotAttributes = {} ) { + var attributes = buildPivotWriteAttributes( arguments.pivotAttributes, true ); + variables.newPivotStatement().insert( parseIdsForInsert( arguments.id, attributes ) ); return variables.parent; } + /** + * Updates an existing intermediate-table row for the parent and related id. + * + * @id The related entity id or composite id values. + * @pivotAttributes The pivot values to update. Pivot keys cannot be overwritten. + * + * @return The number of updated rows. + */ + public any function updateExistingPivot( required any id, required struct pivotAttributes ) { + var attributes = buildPivotWriteAttributes( arguments.pivotAttributes, false ); + for ( var key in duplicate( variables.foreignPivotKeys ).append( variables.relatedPivotKeys, true ) ) { + attributes.delete( key ); + } + + var query = variables.newPivotStatement(); + arrayZipEach( + [ + variables.foreignPivotKeys, + variables.parentKeys + ], + function( foreignPivotKey, parentKey ) { + query.where( foreignPivotKey, variables.parent.retrieveAttribute( parentKey ) ); + } + ); + arrayZipEach( + [ + variables.relatedPivotKeys, + parseIds( arguments.id )[ 1 ] + ], + function( pivotKey, value ) { + query.where( pivotKey, value ); + } + ); + + return query.update( attributes ); + } + /** * Deletes one or more ids of the related entity from the pivot table * where the foreign key is the parent's foreign key value.. @@ -396,7 +744,7 @@ component * * @return quick.models.BaseEntity */ - public any function sync( required any id ) { + public any function sync( required any id, struct pivotAttributes = {} ) { var foreignPivotKeyValues = variables.parentKeys.map( function( parentKey ) { return variables.parent.retrieveAttribute( parentKey ); } ); @@ -414,7 +762,7 @@ component ); } ) .delete(); - return variables.attach( arguments.id ); + return attach( arguments.id, arguments.pivotAttributes ); } /** @@ -455,10 +803,11 @@ component * @doc_generic any,any * @return [{any: any}] */ - public array function parseIdsForInsert( required any value ) { + public array function parseIdsForInsert( required any value, struct pivotAttributes = {} ) { var foreignPivotKeyValues = variables.parentKeys.map( function( parentKey ) { return variables.parent.retrieveAttribute( parentKey ); } ); + var additionalPivotAttributes = arguments.pivotAttributes; return arrayWrap( arguments.value ).map( function( values ) { // If the value is not a simple value, we will assume // it is an entity and return its key value. @@ -485,10 +834,31 @@ component insertRecord[ relatedPivotKey ] = val; } ); + insertRecord.append( additionalPivotAttributes, false ); return insertRecord; } ); } + /** + * Combines configured and supplied values and maintains pivot timestamps. + */ + private struct function buildPivotWriteAttributes( struct attributes = {}, boolean inserting = false ) { + var values = duplicate( variables.pivotValues ); + values.append( arguments.attributes, true ); + + if ( variables.pivotTimestampColumns.len() == 2 ) { + var timestamp = now(); + if ( arguments.inserting && !values.keyExists( variables.pivotTimestampColumns[ 1 ] ) ) { + values[ variables.pivotTimestampColumns[ 1 ] ] = timestamp; + } + if ( !values.keyExists( variables.pivotTimestampColumns[ 2 ] ) ) { + values[ variables.pivotTimestampColumns[ 2 ] ] = timestamp; + } + } + + return values; + } + /** * Gets the query used to check for relation existance. * diff --git a/models/Relationships/Pivot.cfc b/models/Relationships/Pivot.cfc new file mode 100644 index 00000000..68df9604 --- /dev/null +++ b/models/Relationships/Pivot.cfc @@ -0,0 +1,66 @@ +/** + * Represents one intermediate-table row for a belongs-to-many relationship. + * + * The default Pivot model is read-only because its schema is assembled from the + * relationship at runtime. Extend this component and declare the pivot columns + * as properties to opt in to explicit Quick persistence and custom behavior. + */ +component + extends ="quick.models.BaseEntity" + accessors="true" + readonly ="true" +{ + + this.isPivot = true; + + property name="_pivotParent" persistent="false"; + property name="_pivotRelated" persistent="false"; + + /** + * Configures and hydrates this pivot for a relationship result. + */ + public Pivot function configurePivot( + required string table, + required array keyNames, + required struct attributes, + required any parent, + required any related + ) { + set_table( arguments.table ); + set_key( arguments.keyNames ); + variables._pivotParent = arguments.parent; + variables._pivotRelated = arguments.related; + + for ( var attributeName in arguments.attributes ) { + if ( + !retrieveAttributeNames( withVirtualAttributes = true, withExcludedAttributes = true ).findNoCase( + attributeName + ) && + !retrieveColumnNames( withVirtualAttributes = true ).findNoCase( attributeName ) + ) { + appendVirtualAttribute( attributeName ); + } + } + + this.memento.defaultIncludes = retrieveAttributeNames( withVirtualAttributes = true ); + + return assignAttributesData( arguments.attributes ) + .assignOriginalAttributes( arguments.attributes ) + .markLoaded(); + } + + /** + * Returns the parent entity which loaded this pivot. + */ + public any function getPivotParent() { + return variables._pivotParent; + } + + /** + * Returns the related entity carrying this pivot. + */ + public any function getPivotRelated() { + return variables._pivotRelated; + } + +} diff --git a/tests/resources/app/models/Post.cfc b/tests/resources/app/models/Post.cfc index eeb84f1d..badd9f42 100644 --- a/tests/resources/app/models/Post.cfc +++ b/tests/resources/app/models/Post.cfc @@ -88,6 +88,17 @@ component .orderByPivot( "context" ); } + function defaultActiveTags() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ) + .withPivot( [ "context", "active" ] ) + .withPivotValue( "active", true ); + } + function timestampedTags() { return belongsToMany( "Tag", diff --git a/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc b/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc index 2e472352..774ad4ae 100644 --- a/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc @@ -31,13 +31,15 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( pivot.getTag_id() ).toBe( tag.getId() ); expect( pivot.getContext() ).toBe( "primary" ); expect( pivot.getActive() ).toBeTrue(); - expect( pivot.getPivotParent() ).toBe( post ); - expect( pivot.getPivotRelated() ).toBe( tag ); - expect( pivot.getMemento() ).toInclude( { - "custom_post_pk" : 1245, - "tag_id" : 1, - "context" : "primary" - } ); + expect( pivot.getPivotParent().getPost_pk() ).toBe( post.getPost_pk() ); + expect( pivot.getPivotRelated().getId() ).toBe( tag.getId() ); + var memento = pivot.getMemento(); + expect( memento.custom_post_pk ).toBe( 1245 ); + expect( memento.tag_id ).toBe( 1 ); + expect( memento.context ).toBe( "primary" ); + expect( function() { + pivot.setContext( "not persisted" ).save(); + } ).toThrow( "QuickReadOnlyException" ); } ); it( "hydrates the correct pivot for every eagerly loaded parent", function() { @@ -68,6 +70,10 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( pivot ).toBeInstanceOf( "app.models.PostTag" ); expect( pivot.getActive() ).toBeBoolean().toBeTrue(); expect( pivot.describe() ).toBe( "primary:1" ); + + pivot.setContext( "saved through custom pivot" ).save(); + var refreshed = getInstance( "Post" ).findOrFail( 1245 ).getTagsWithCustomPivot()[ 1 ].getPivot(); + expect( refreshed.getContext() ).toBe( "saved through custom pivot" ); } ); it( "can constrain and order by pivot columns", function() { @@ -78,6 +84,41 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( tags[ 2 ].getPivot().getContext() ).toBe( "review" ); } ); + it( "supports the pivot query helper family", function() { + var post = getInstance( "Post" ).findOrFail( 1245 ); + + expect( + post.tagsWithPivot() + .wherePivotIn( "tag_id", [ 1 ] ) + .get() + ).toHaveLength( 1 ); + expect( + post.tagsWithPivot() + .wherePivotNotIn( "tag_id", [ 1 ] ) + .get() + ).toHaveLength( 1 ); + expect( + post.tagsWithPivot() + .wherePivotBetween( "tag_id", 1, 2 ) + .get() + ).toHaveLength( 2 ); + expect( + post.tagsWithPivot() + .wherePivotNotBetween( "tag_id", 2, 2 ) + .get() + ).toHaveLength( 1 ); + expect( + post.tagsWithPivot() + .wherePivotNull( "created_date" ) + .get() + ).toHaveLength( 2 ); + expect( + post.tagsWithPivot() + .wherePivotNotNull( "context" ) + .get() + ).toHaveLength( 2 ); + } ); + it( "writes and updates additional pivot attributes", function() { var post = getInstance( "Post" ).findOrFail( 1245 ); @@ -99,6 +140,16 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( updated.getPivot().getActive() ).toBeFalse(); } ); + it( "applies configured pivot values to constraints and writes", function() { + var post = getInstance( "Post" ).findOrFail( 321 ); + + post.defaultActiveTags().attach( 3, { "context" : "defaulted" } ); + var tag = post.defaultActiveTags().findOrFail( 3 ); + + expect( tag.getPivot().getActive() ).toBeTrue(); + expect( tag.getPivot().getContext() ).toBe( "defaulted" ); + } ); + it( "maintains configured pivot timestamps", function() { var post = getInstance( "Post" ).findOrFail( 321 ); From b18a1f0f045b4c81f3012d94cd61f09282009aeb Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 12:30:50 -0600 Subject: [PATCH 3/7] fix: support pivot models on Adobe CF --- models/Relationships/BelongsToMany.cfc | 33 +++++++++++++++++++++----- models/Relationships/Pivot.cfc | 4 ++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/models/Relationships/BelongsToMany.cfc b/models/Relationships/BelongsToMany.cfc index 61f58cb8..f60c8ac6 100644 --- a/models/Relationships/BelongsToMany.cfc +++ b/models/Relationships/BelongsToMany.cfc @@ -561,9 +561,17 @@ component * Adds any pivot columns not already present to the related select list. */ private void function addPivotSelects() { - var columns = duplicate( variables.foreignPivotKeys ) - .append( variables.relatedPivotKeys, true ) - .append( variables.pivotColumns, true ); + var columns = duplicate( variables.foreignPivotKeys ); + arrayAppend( + columns, + variables.relatedPivotKeys, + true + ); + arrayAppend( + columns, + variables.pivotColumns, + true + ); for ( var column in columns ) { if ( variables.pivotColumnAliases.keyExists( column ) ) { @@ -611,9 +619,16 @@ component } } + var keyNames = duplicate( variables.foreignPivotKeys ); + arrayAppend( + keyNames, + variables.relatedPivotKeys, + true + ); + pivot.configurePivot( table = listFirst( variables.table, " " ), - keyNames = duplicate( variables.foreignPivotKeys ).append( variables.relatedPivotKeys, true ), + keyNames = keyNames, attributes = attributes, parent = variables.parent, related = arguments.entity @@ -652,8 +667,14 @@ component * @return The number of updated rows. */ public any function updateExistingPivot( required any id, required struct pivotAttributes ) { - var attributes = buildPivotWriteAttributes( arguments.pivotAttributes, false ); - for ( var key in duplicate( variables.foreignPivotKeys ).append( variables.relatedPivotKeys, true ) ) { + var attributes = buildPivotWriteAttributes( arguments.pivotAttributes, false ); + var protectedKeys = duplicate( variables.foreignPivotKeys ); + arrayAppend( + protectedKeys, + variables.relatedPivotKeys, + true + ); + for ( var key in protectedKeys ) { attributes.delete( key ); } diff --git a/models/Relationships/Pivot.cfc b/models/Relationships/Pivot.cfc index 68df9604..75321fd4 100644 --- a/models/Relationships/Pivot.cfc +++ b/models/Relationships/Pivot.cfc @@ -11,11 +11,11 @@ component readonly ="true" { - this.isPivot = true; - property name="_pivotParent" persistent="false"; property name="_pivotRelated" persistent="false"; + this.isPivot = true; + /** * Configures and hydrates this pivot for a relationship result. */ From 37305306ade0569f3d874875bd7b0cc17b88892c Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 12:38:44 -0600 Subject: [PATCH 4/7] fix: resolve default pivots with full null support --- models/Relationships/BelongsToMany.cfc | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/models/Relationships/BelongsToMany.cfc b/models/Relationships/BelongsToMany.cfc index f60c8ac6..955a2a5d 100644 --- a/models/Relationships/BelongsToMany.cfc +++ b/models/Relationships/BelongsToMany.cfc @@ -599,8 +599,9 @@ component attributes[ column ] = isNull( value ) ? javacast( "null", "" ) : value; } - var mapping = isNull( variables.pivotEntity ) ? "Pivot@quick" : variables.pivotEntity; - var pivot = variables.wirebox.getInstance( mapping ); + var hasCustomPivot = structKeyExists( variables, "pivotEntity" ); + var mapping = hasCustomPivot ? variables.pivotEntity : "Pivot@quick"; + var pivot = variables.wirebox.getInstance( mapping ); if ( !structKeyExists( pivot, "isPivot" ) ) { throw( type = "QuickInvalidPivotModel", @@ -608,7 +609,7 @@ component ); } - if ( !isNull( variables.pivotEntity ) ) { + if ( hasCustomPivot ) { for ( var attributeName in attributes ) { if ( !pivot.hasAttribute( attributeName ) ) { throw( From c22307f2421bfce8907ec0ff41781f0554310932 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 12:49:03 -0600 Subject: [PATCH 5/7] fix: resolve default pivots on BoxLang --- models/Relationships/BelongsToMany.cfc | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/models/Relationships/BelongsToMany.cfc b/models/Relationships/BelongsToMany.cfc index 955a2a5d..be70d113 100644 --- a/models/Relationships/BelongsToMany.cfc +++ b/models/Relationships/BelongsToMany.cfc @@ -136,6 +136,7 @@ component variables.pivotColumnAliases = {}; variables.pivotValues = {}; variables.pivotTimestampColumns = []; + variables.pivotEntity = ""; super.init( related = arguments.related, @@ -400,6 +401,9 @@ component * Uses a custom Pivot entity mapping for hydrated intermediate rows. */ public BelongsToMany function using( required string pivotEntity ) { + if ( !len( trim( arguments.pivotEntity ) ) ) { + throw( type = "QuickInvalidPivotModel", message = "A custom pivot model mapping cannot be empty." ); + } variables.pivotEntity = arguments.pivotEntity; return this; } @@ -599,7 +603,7 @@ component attributes[ column ] = isNull( value ) ? javacast( "null", "" ) : value; } - var hasCustomPivot = structKeyExists( variables, "pivotEntity" ); + var hasCustomPivot = len( variables.pivotEntity ) > 0; var mapping = hasCustomPivot ? variables.pivotEntity : "Pivot@quick"; var pivot = variables.wirebox.getInstance( mapping ); if ( !structKeyExists( pivot, "isPivot" ) ) { From 408ac4070e743f0fee540462861268022e8d1443 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 12:17:36 -0600 Subject: [PATCH 6/7] test: define belongs-to-many create behavior (#84) --- .../Relationships/BelongsToManySpec.cfc | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc b/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc index 774ad4ae..7254365e 100644 --- a/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc @@ -162,6 +162,26 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( pivot.getCreated_date() ).notToBeNull(); expect( pivot.getModified_date() ).notToBeNull(); } ); + + it( "creates and attaches a related entity", function() { + var post = getInstance( "Post" ).findOrFail( 1245 ); + var tag = post + .tagsWithPivot() + .create( + { "name" : "testing" }, + { + "context" : "created through relationship", + "active" : true + } + ); + + expect( tag ).toBeInstanceOf( "Tag" ); + expect( tag.isLoaded() ).toBeTrue(); + + var attached = post.tagsWithPivot().findOrFail( tag.getId() ); + expect( attached.getPivot().getContext() ).toBe( "created through relationship" ); + expect( attached.getPivot().getActive() ).toBeTrue(); + } ); } ); } From 6b23498d7b56e35cb989326639684bf40ce8d2f4 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 24 Aug 2026 12:20:26 -0600 Subject: [PATCH 7/7] feat: create belongs-to-many related entities (#84) --- models/Relationships/BelongsToMany.cfc | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/models/Relationships/BelongsToMany.cfc b/models/Relationships/BelongsToMany.cfc index be70d113..7bf587cc 100644 --- a/models/Relationships/BelongsToMany.cfc +++ b/models/Relationships/BelongsToMany.cfc @@ -649,6 +649,31 @@ component return find( ".", arguments.column ) ? arguments.column : "#listLast( variables.table, " " )#.#arguments.column#"; } + /** + * Creates a new related entity and attaches it to the parent through the pivot table. + * + * @attributes Attributes for the related entity. + * @pivotAttributes Additional attributes for the pivot row. + * @ignoreNonExistentAttributes Whether to ignore attributes not defined on the related entity. + * @options Options passed to the related entity save query. + * + * @return quick.models.BaseEntity + */ + public any function create( + struct attributes = {}, + struct pivotAttributes = {}, + boolean ignoreNonExistentAttributes = false, + struct options = {} + ) { + var entity = variables.related.create( + arguments.attributes, + arguments.ignoreNonExistentAttributes, + arguments.options + ); + attach( entity, arguments.pivotAttributes ); + return entity; + } + /** * Associates one or more ids of the related entity to the parent entity. *