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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,54 @@ component extends="quick.models.BaseEntity" {

Query caching stores database results, not live Quick entities or loaded relationships. Cache lifetime and invalidation are managed by the CFML engine, so use short lifetimes for data that Quick or another process may update. For application-specific invalidation or distributed caching, cache entity mementos in CacheBox at the service layer and rehydrate them through Quick's public APIs.

### Testing with model factories

Quick includes Laravel-inspired model factories under `quick.resources.testing`. Define application factories outside of your production model code:

```javascript
// tests/resources/factories/UserFactory.cfc
component extends="quick.resources.testing.Factory" {

struct function definition() {
return {
username : "factory-#lCase( createUUID() )#",
firstName : "Factory",
lastName : "User"
};
}

any function administrator() {
return state( { type : "admin" } );
}

}
```

Create a manager in your test base class and expose a short `factory()` helper:

```javascript
variables.factoryManager = new quick.resources.testing.FactoryManager(
wirebox = getWireBox(),
factoryPath = "tests.resources.factories"
);

any function factory( required string name ) {
return variables.factoryManager.factory( arguments.name );
}
```

Factories support default definitions, explicit and named states, counts, sequences, attribute closures, and `afterMaking` and `afterCreating` callbacks. `make()` returns unsaved Quick entities, while `create()` persists through the entity's normal `save()` lifecycle:

```javascript
var admin = factory( "User" ).administrator().create();
var users = factory( "User" ).count( 3 ).create();
var unsavedUser = factory( "User" ).make( { firstName : "Override" } );
```

Factories do not manage database transactions. Integration tests should start a transaction around each test and roll it back in `finally`, ensuring both passing and failing tests leave the database unchanged.

All factory implementation classes are isolated beneath `resources/testing`; production deployment tooling may exclude that directory. Quick does not load or register these classes during normal module startup.

### Tests and Contributing

To run the tests, first clone this repo and run a `box install`.
Expand Down
6 changes: 3 additions & 3 deletions box.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
"shortDescription":"A ColdBox ORM Engine",
"description":"A ColdBox ORM Engine",
"scripts":{
"format":"cfformat run dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,tests/specs/**/*.cfc --overwrite",
"format:check":"cfformat check dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,tests/specs/**/*.cfc --verbose",
"format:watch":"cfformat watch dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,tests/specs/**/*.cfc",
"format":"cfformat run dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,resources/testing/**/*.cfc,tests/resources/factories/**/*.cfc,tests/specs/**/*.cfc --overwrite",
"format:check":"cfformat check dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,resources/testing/**/*.cfc,tests/resources/factories/**/*.cfc,tests/specs/**/*.cfc --verbose",
"format:watch":"cfformat watch dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,resources/testing/**/*.cfc,tests/resources/factories/**/*.cfc,tests/specs/**/*.cfc",
"generateAPIDocs":"rm .tmp --recurse --force && docbox generate mapping=quick excludes=test|/modules|ModuleConfig|QuickCollection strategy-outputDir=.tmp/apidocs strategy-projectTitle=Quick",
"install:2021":"cfpm install document,feed,mysql,zip",
"bx-modules:install":"install bx-compat-cfml@be,bx-esapi,bx-mysql"
Expand Down
99 changes: 99 additions & 0 deletions resources/testing/Factory.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Base class for Laravel-inspired Quick model factories.
*
* Factory support lives under `resources/testing` so applications can omit the
* entire directory from production deployments. Subclasses provide
* `definition()` and may expose named states which return `state( ... )`.
*/
component {

/**
* Create a factory for a Quick entity provider.
*
* @entityProvider A WireBox provider for the Quick entity mapping.
* @context Optional application-specific values available to definitions and states.
*/
public any function init( required any entityProvider, struct context = {} ) {
variables.entityProvider = arguments.entityProvider;
variables.factoryContext = arguments.context;
variables.afterMakingCallbacks = [];
variables.afterCreatingCallbacks = [];
configure();
return this;
}

/**
* Return the default attributes for one entity.
*/
public struct function definition() {
throw( type = "QuickFactory.AbstractMethod", message = "Factory subclasses must implement definition()." );
}

/**
* Register factory-wide callbacks in subclasses.
*/
public any function configure() {
return this;
}

public any function state( required any transformation ) {
return newBuilder().state( arguments.transformation );
}

public any function sequence( required array states ) {
return newBuilder().sequence( arguments.states );
}

public any function count( required numeric amount ) {
return newBuilder().count( arguments.amount );
}

public any function make( struct attributes = {} ) {
return newBuilder().make( arguments.attributes );
}

public any function create( struct attributes = {} ) {
return newBuilder().create( arguments.attributes );
}

public any function afterMaking( required any callback ) {
if ( !isCallable( arguments.callback ) ) {
throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." );
}
arrayAppend( variables.afterMakingCallbacks, arguments.callback );
return this;
}

public any function afterCreating( required any callback ) {
if ( !isCallable( arguments.callback ) ) {
throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." );
}
arrayAppend( variables.afterCreatingCallbacks, arguments.callback );
return this;
}

public struct function getFactoryContext() {
return variables.factoryContext;
}

public any function newEntity( required struct attributes ) {
return variables.entityProvider.newEntity().fill( arguments.attributes );
}

public array function getAfterMakingCallbacks() {
return variables.afterMakingCallbacks;
}

public array function getAfterCreatingCallbacks() {
return variables.afterCreatingCallbacks;
}

private any function newBuilder() {
return new quick.resources.testing.FactoryBuilder( this );
}

private boolean function isCallable( required any candidate ) {
return isClosure( arguments.candidate ) || isCustomFunction( arguments.candidate );
}

}
216 changes: 216 additions & 0 deletions resources/testing/FactoryBuilder.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/**
* A one-use fluent builder produced by a Quick factory definition.
*/
component {

public any function init( required any factory ) {
variables.factory = arguments.factory;
variables.amount = 1;
variables.explicitCount = false;
variables.transformations = [];
variables.afterMakingCallbacks = [];
variables.afterCreatingCallbacks = [];
return this;
}

public any function count( required numeric amount ) {
if ( arguments.amount < 0 || int( arguments.amount ) != arguments.amount ) {
throw( type = "QuickFactory.InvalidCount", message = "Factory count must be a non-negative integer." );
}
variables.amount = int( arguments.amount );
variables.explicitCount = true;
return this;
}

public any function state( required any transformation ) {
if ( !isStruct( arguments.transformation ) && !isCallable( arguments.transformation ) ) {
throw( type = "QuickFactory.InvalidState", message = "Factory state must be a struct or closure." );
}
arrayAppend( variables.transformations, arguments.transformation );
return this;
}

public any function sequence( required array states ) {
arrayAppend( variables.transformations, new quick.resources.testing.Sequence( arguments.states ) );
return this;
}

public any function afterMaking( required any callback ) {
if ( !isCallable( arguments.callback ) ) {
throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." );
}
arrayAppend( variables.afterMakingCallbacks, arguments.callback );
return this;
}

public any function afterCreating( required any callback ) {
if ( !isCallable( arguments.callback ) ) {
throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." );
}
arrayAppend( variables.afterCreatingCallbacks, arguments.callback );
return this;
}

/**
* Forward named state methods to the factory definition so chains may use
* either `factory.count( 3 ).inactive()` or `factory.inactive().count( 3 )`.
*/
public any function onMissingMethod( required string missingMethodName, required struct missingMethodArguments ) {
if ( !structKeyExists( variables.factory, arguments.missingMethodName ) ) {
throw(
type = "QuickFactory.UnknownMethod",
message = "Unknown factory method [#arguments.missingMethodName#]."
);
}
var stateBuilder = invoke(
variables.factory,
arguments.missingMethodName,
arguments.missingMethodArguments
);
if ( !isInstanceOf( stateBuilder, "quick.resources.testing.FactoryBuilder" ) ) {
return stateBuilder;
}
arrayAppend(
variables.transformations,
stateBuilder.getTransformations(),
true
);
arrayAppend(
variables.afterMakingCallbacks,
stateBuilder.getAfterMakingCallbacks(),
true
);
arrayAppend(
variables.afterCreatingCallbacks,
stateBuilder.getAfterCreatingCallbacks(),
true
);
return this;
}

public array function getTransformations() {
return variables.transformations;
}

public array function getAfterMakingCallbacks() {
return variables.afterMakingCallbacks;
}

public array function getAfterCreatingCallbacks() {
return variables.afterCreatingCallbacks;
}

/**
* Build Quick entities without persisting them.
*/
public any function make( struct attributes = {} ) {
var entities = [];
for ( var index = 1; index <= variables.amount; index++ ) {
var evaluatedAttributes = evaluateAttributes(
attributes = arguments.attributes,
index = index,
count = variables.amount
);
var entity = variables.factory.newEntity( evaluatedAttributes );
runCallbacks(
variables.factory.getAfterMakingCallbacks(),
entity,
evaluatedAttributes
);
runCallbacks(
variables.afterMakingCallbacks,
entity,
evaluatedAttributes
);
arrayAppend( entities, entity );
}
return variables.explicitCount ? entities : entities[ 1 ];
}

/**
* Build and persist Quick entities through `BaseEntity.save()`.
*/
public any function create( struct attributes = {} ) {
var entities = make( arguments.attributes );
var collection = variables.explicitCount ? entities : [ entities ];
for ( var entity in collection ) {
entity.save();
var persistedAttributes = entity.retrieveAttributesData();
runCallbacks(
variables.factory.getAfterCreatingCallbacks(),
entity,
persistedAttributes
);
runCallbacks(
variables.afterCreatingCallbacks,
entity,
persistedAttributes
);
}
return variables.explicitCount ? collection : collection[ 1 ];
}

private struct function evaluateAttributes(
required struct attributes,
required numeric index,
required numeric count
) {
var definition = variables.factory.definition();
if ( !isStruct( definition ) ) {
throw( type = "QuickFactory.InvalidDefinition", message = "Factory definitions must return a struct." );
}

var values = copyStruct( definition );
var context = {
index : arguments.index - 1,
count : arguments.count
};

for ( var transformation in variables.transformations ) {
var changes = {};
if ( isInstanceOf( transformation, "quick.resources.testing.Sequence" ) ) {
changes = transformation.next( copyStruct( values ), context );
} else if ( isStruct( transformation ) ) {
changes = transformation;
} else if ( isCallable( transformation ) ) {
changes = transformation( copyStruct( values ), context );
}
if ( !isStruct( changes ) ) {
throw(
type = "QuickFactory.InvalidStateResult",
message = "Factory state transformations must return a struct."
);
}
structAppend( values, changes, true );
}

structAppend( values, arguments.attributes, true );
for ( var key in values ) {
if ( !isNull( values[ key ] ) && isCallable( values[ key ] ) ) {
values[ key ] = values[ key ]( copyStruct( values ), context );
}
}
return values;
}

private void function runCallbacks(
required array callbacks,
required any entity,
required struct attributes
) {
for ( var callback in arguments.callbacks ) {
callback( arguments.entity, arguments.attributes );
}
}

private struct function copyStruct( required struct source ) {
var copied = {};
structAppend( copied, arguments.source, true );
return copied;
}

private boolean function isCallable( required any candidate ) {
return isClosure( arguments.candidate ) || isCustomFunction( arguments.candidate );
}

}
Loading
Loading