[Quick 5] What is the Optimal Way to Return a Subset of Properties from a Relationship?

When listing a bunch of entities, I would like to return a subset of data to each entity associated with a belongsTo() relationship.

For example, let’s say I have a Cart entity, and each cart item is associated with a Product via belongsTo(). When retrieving a list of cart entities, I only want the Product.name and Product.price fields, without the rest of the model.

The way I am currently doing it is by eager loading the relationship and then excluding fields I don’t want in the memento definition like this:

// Handler method index()
getInstance( "Cart" )
	.with( [ "Product" ] )
	.asMemento( "product" )
	.get();

// Cart.cfc
function instanceReady() {

	this.memento.defaultExcludes = [
		"product.description",
		"product.limit",
		"product.expireDate",
		// etc... 
	];

}

The end result looks like this:

[
	{
		"id": 1,
		"userId": 202,
		"productId": 334,
		"product": {
			"name": "My Product",
			"price": 100.00
		}
	}
]

Excluding the fields is a bit of a pain, because whenever the Product entity changes, I have to remember to add any new properties to the exclude list. I also feel like there must be a more efficient way to accomplish the same result without having to query the entire Product entity when I just need a few fields.

I experimented with a few different ideas already:

Idea 1: Subselects
From what I can tell from the docs, adding a subselect will append the virtual attributes at the root of the entity, and I want to maintain the nested object structure.

I was thinking something like this would work, but it does not:

function scopeAddProductBasic( qb ) {
        return qb.addSubselect( "productName,productPrice", newEntity( "Product" )
        .select( "name" )
        .whereColumn( "id", "cart.productId" )
    ).with( "productName" );
}

The above code modifies the original query by adding a join, but doesn’t allow for multiple columns, and nothing gets returned in the actual SELECT statement.

Idea 2: Subquery
Perhaps a manual subquery is a way to go:

function scopeAddProductBasic( query ) {
	var sub = query.newQuery()
		.select( [ "product.name", "product.price" ] )
		.from( "product" )
	;
	query.leftJoinSub( "product", sub, "product.id", "=", "cart.productId" );
}

Again, the above code modifies the original query by adding a join, but there’s no way that I can see to define the columns as a nested object. The columns get appended to the original query. I would have to write a complex memento.mapper function combined with virtualAttributes to accomplish the task.

Idea 3: New Entity
Maybe this is a circumstance where I need to create a new entity with just the properties I need. For example:

// Cart.cfc
// the full relationship used for Cart behavior/calculations
function product() {
	return BelongsTo( "Product", "productId" );
}

// when you need to return just the basics
function ProductBasic() {
	return BelongsTo( "ProductBasic", "productId" );
}

The above approach works with one caveat. The resulting memento sub-object will be called productBasic. This could probably be fixed with a memento.mapper though.

Aside from the above options, is there anything obvious I am missing for the best way to handle this type of thing? I am leaning towards creating the New Entity approach and a custom memento.mapper, but would love to hear other ideas.

The asMemento method should be able to handle it by passing a delimited includes list.

asMemento(
    includes = [ “product.name”, “product.price” ],
)

You may need to ignoreDefaults with this approach.

1 Like

Thanks, Eric.

I messed around with the memento settings. adding includes = [ "product.name", "product.price" ] still returns the entire product model unless I ignoreDefaults=true

Overriding defaults works, but I have to manually specify everything in the Cart model from scratch, which is not ideal.

My current thinking (I need to test it) is that this is a case for setting up dynamic mementifier profiles within the model. So I can do something like this for various API endpoints:

// index
asMemento( profile="simple" );

// new
asMemento( profile="new" );

// show
asMemento( profile="full" );

I’m torn on the idea of offloading memento rules into the model layer, since that feels more like a handler concern to me… but I suppose I could go either way on that.

This is a bit off-topic, but I kept encountering the guardAgainstNotLoaded() error when testing various memento patterns with new, unpersisted, entities with Quick. Bypassing the guard doesn’t work in Quick 5 due to some changes in how relationships work. I created a PR to address the issue with the exception being thrown: Fix Required Parameter Error When Null by homestar9 · Pull Request #209 · coldbox-modules/quick · GitHub

As an added benefit , the above PR fix combined with disabling the loaded guard, should allow developers to use the null object pattern by specifying memento defaults when a relationship is null like this:

// inside a Quick entity
private void function setUpMementifier() {
	super.setupMementifier();
	this.memento.defaults = {
		"product": {}
	};
}

Here’s the solution that seems to work best (as of now):

I set up the default mementifier settings within the Cart entity. I used a combination of defaults and mappers to achieve my goal of returning a subset of relationship data.

private void function setUpMementifier() {
	super.setupMementifier();
	
	// set up the default product relationship (if this is a new entity)
	this.memento.defaults = {
		"product": {}
	};
	
	// Use mappers to return the exact subset of properties for the product relationship
	this.memento.mappers = {
		"product": function( item, memento ) {
			if ( item.isEmpty() ) {
				return {}; // return an empty struct if product doesn't exist
			}
			return {
				"id": item.id,
				"price": item.price,
				"name": item.name
			};
		}
	};
}

This approach could be extended to use additional profiles. So, for example, if you need to send a different data packet to a ERP or 3rd party API, you could do something like this:

    // ... after the above code
	
	// 3rd party ERP needs a different data packet
	this.memento.profiles[ "erp" ] = {
		neverInclude : [ "createdDate", "modifiedDate", "createdBy", "modifiedBy" ],
		mappers = {
			"product": function( item, memento ) {
				if ( item.isEmpty() ) {
					return {};
				}
				return {
					"id": item.id,
					"price": item.price,
					"name": item.name,
					"sku": item.sku,
					"weight": item.weight
				};
			}
		}
	};