ApexEloquent v2: What's New
A while back I shipped ApexEloquent v2.0.0! 🎉
(There was a feature I urgently wanted to add, so the version has already moved on to v2.0.x.)
In this post I want to walk through the new features that landed in this release!
Unified Error Messages
The library used to throw the standard QueryException, but I changed that to throw ApexEloquentException instead.
This ApexEloquentException bundles into one log message:
- where it happened
- what kind of error it was
- the reason
- what action to take next
It's easy to read for humans, of course, but also clear enough for an AI to know what to do next, so you're less likely to get stuck on how to use ApexEloquent!
Scribe
A New of Factory Method
The Scribe entry point used to look like this:
Scribe oppScribe = Scribe.source(Opportunity.getSObjectType());
— a bit long and hard on the eyes, so to align with ApexBlueprint, you can now declare it like:
Scribe oppScribe = Scribe.of(Opportunity.class);
Readability gets noticeably better, especially in nested queries!
Faster Child-Relationship Resolution
When you write a child relationship in Scribe like:
Scribe scribe = Scribe.of(Account.class)
.field('Name')
.withChildren(
Scribe.asChild(Contact.class)
.fields(new List<String>{'Id', 'Email'})
.whereNotNull('Email')
);
it generates this SOQL:
SELECT name, (SELECT id, email FROM Contacts WHERE Email != NULL) FROM Account
The asChild(Contact.class) part dynamically figures out the relationship name by inspecting the parent Account — and I switched this resolution to use binary search.
I'll go deeper in another post, but since the child object names on a parent are already sorted in Unicode order, I dropped a binary search in there.
The number of calls to getDescribe (a heavy operation) dropped substantially, so test execution time went down with v2!
This binary search is robust as your org grows and the number of relationships increases, so it should keep paying back even more for larger orgs.
IEntry
getChildrenByRelationName Merged into getChildren
When you specified Scribe.relationName('xxx__r'), you used to have to retrieve children via getChildrenByRelationName('xxx__r') rather than getChildren('xxx__r') — and Claude kept tripping on this, so I made getChildren('xxx__r') work too.
AI-friendly! 🙌
MockEntry
I added a lot of mock-data features. Writing tests should be more comfortable, and more fun!
A New of Factory Method
Just like Scribe, you can now write:
MockEntry.of(Opportunity.class)
Cleaner, right?
Turning Off SELECT-Omission Detection
Scribe lets you list fields to SELECT with field / fields, and if you try to access a field that wasn't in that list, the mock throws an error — that's the "SELECT-omission detection". This new feature is the switch to turn that off.
Concretely:
MockEntry.withoutFieldValidation()
Just drop withoutFieldValidation() into the chain!
There must be a corner case somewhere where this comes in handy!
Expressing Aggregate-Result Mocks with asAggregateResult
MockEntry can mock AggregateResult too, but there used to be a knack to declaring it, so now you can express "this is an aggregate mock" clearly like:
MockEntry.asAggregateResult().set('count', 10);
Field Setting via set
Field setting used to go through add, and now I added set.
add and set behave identically, but add is scheduled for removal in v3.
Writing tests myself, I kept reaching for set to set a field — that made me suspect add wasn't the more conventional verb, so I added set!
Aliases, and Pulling Things Out by Alias
When you set an id or a value like this, there used to be no way to retrieve it back:
MockEntry oppEntry = MockEntry.of(Opportunity.class).autoId(1).set('Name', 'TestOpp');
But while writing tests, I kept running into situations where I wanted the Id of the oppEntry I'd just built. So I added these APIs:
aliasgetByAliasgetAliasId
How to use them:
- Attach an alias to a
MockEntrywithalias - Use
getByAliasto retrieve thatMockEntry(it walks parent and child links automatically too!) - Use
getAliasIdif you just want the Id
MockEntry oppEntry = MockEntry.of(Opportunity.class)
.alias('opp') // attach alias 'opp'
.autoId(1)
.set('Name', 'TestOpp');
// pull the Id directly
Id mockOppId = oppEntry.getAliasId('opp');
// pull the MockEntry itself
MockEntry mockOpp = oppEntry.getByAlias('opp');
String mockOppName = mockOpp.getName();
This is incredibly handy when writing Asserts! Highly recommended!
The template Method
When you're setting up test data and the same set of fields keeps showing up, that's where template shines!
private static Map<String, Object> getTemplate() {
return new Map<String, Object>{
'Name' => 'testOpp'
...
}
}
Prepare a method that returns frequently-used fields like the above, then in your test class:
MockEntry oppEntry = MockEntry.of(Opportunity.class)
.template(xxxTest.getTemplate())
.set(...)
— and the common setup lands in one line! DRY up your tests cleanly!
IEloquent
getAggregate Merged into get
It was confusingly subtle — even I got confused — so I just merged them!
Now Accepts Raw SOQL
Where previously you had to go through Scribe, you can now hand a raw SOQL string instead!
String soql = 'SELECT Id, Name FROM Opportunity LIMIT 10';
List<IEntry> oppEntries = (new Eloquent()).rawSoql(soql);
Note: when you take this path, the "SELECT-omission detection" is forcibly turned off, so be careful!
MockEloquent
Adding the failOnXxx Family
MockEloquent could already throw errors intentionally on get, doUpdate and friends, but you had to pass the error as the second constructor argument, which was awkward — so I added methods like:
failOnGetfailOnFirstfailOnDoInsertfailOnDoUpsert
… in the failOnXxx family!
IEloquent eloquent = (new MockEloquent(mockEntry)).failOnGet();
Define it like this, DI it in, and the moment get is called, an error is thrown!
That makes verifying catch blocks and retry logic even smoother!
Adding deletedCount
MockEloquent now records how many records were passed to doDelete. With that:
// Production code
this.eloquent.doDelete(deleteTargets);
// Test class
Assert.areEqual(10, this.eloquent.deletedCount);
— delete-related asserts are now possible!