Up and Running
with
Getting Started
Who Am I?
Utah
Ortus Solutions
Prolific Module Author
1 wife, 3 kids, 1 dog
Type 1 Diabetic
Introductions
- Name?
- Company?
- How long have you used CFML?
- How long have you used ColdBox?
- What are you hoping to get out of this workshop?
- Any specific questions you hope will be answered?
Installation and Setup
- Get started "quickly" using the `quick-with-auth` template on ForgeBox.
- Configure our Application.
- Take a tour of what is provided from the template.
- Scaffold a quick-blog-example app from the quick-with-auth template.
- Configure Quick in your .env file and application to work with your database.
- Run your migrations against your database.
- Start Server and ensure that registration and logging in is successful.
Break
15 Minutes
The
Philosophy
-
Give relevant names to important collections of SQL code. (scopes, relationships, etc.)
-
Make queries easy to compose at runtime to get the exact data you want in the most efficient way (subselects, eager loading, etc.)
-
Get out of your way when you need or want to write barebones SQL.
The Quick Philosophy
-
Map to a database table.
-
Expose columns as properties.
-
Expresses relationships between entities
-
Can define custom logic as needed
Entities
component extends="quick.models.BaseEntity" accessors="true" {
property name="bcrypt" inject="@BCrypt" persistent="false";
property name="id";
property name="email";
property name="password" column="hashed_password";
public User function setPassword( required string password ){
return assignAttribute(
"password",
bcrypt.hashPassword( arguments.password )
);
}
public struct function getMemento(){
return { "email" : variables.getEmail() };
}
}
Let's Tour our App!
- Add a new migration to add firstName and lastName columns to the database.
- Add firstName and lastName attributes to the User entity.
- Add fields to the registration form for the new fields.
- Validate that the new fields are required when submitting the form.
- Save all the registration fields to the database.
- Show the user's full name on the navbar when logged in.
Goal: Capture addition user information in the registration form.
Lunch
1 hour
var users = getInstance( "User" ).all();
var posts = getInstance( "Post" )
.whereNotNull( "publishedDate" )
.get();
var posts = getInstance( "Post" )
.whereNotNull( "publishedDate" )
.paginate( rc.page, rc.maxrows );
Returns an array of entities
- Create a migration for a posts table.
- Create a Quick entity for a Post.
- Create a posts handler and index action to show all posts.
- Show all posts on the index view.
- Provide an empty state view in posts.index.
- Add a link to create a new post.
- Link to the new posts.index route from the navbar.
Goal: Show all existing Posts
-
Define how two entities are connected.
-
Extendible at runtime.
-
Can be eager or lazy loaded.
Relationships
component extends="quick.models.BaseEntity" accessors="true" {
function author() {
return belongsTo( "User" );
// same as
// return belongsTo( "User", "userID", "ID" );
}
}
var user = getInstance( "User" ).findOrFail( 1 );
var post = getInstance( "Post" ).findOrFail( 1 );
post.author().associate( user ); // does not save automatically!
// same as
post.setAuthor( user );
post.save(); // now the database has been updated.
// Returns a single related entity
var userAgain = post.getAuthor();
user.isSameAs( userAgain ); // true
// Returns a relationship query
var qb = post.author();
// Manually set fields and manually call `save`
var user = getInstance( "User" );
user.setUsername( "JaneDoe" );
user.setEmail( "jane@example.com" );
user.setPassword( "mypass1234" );
user.save();
// Set fields using `fill` and manually call `save`
var user = getInstance( "User" );
user.fill( {
"username": "JaneDoe",
"email": "jane@example.com",
"password": "mypass1234"
} );
user.save();
// Set fields and save entity all at once
var user = getInstance( "User" ).create( {
"username": "JaneDoe",
"email": "jane@example.com",
"password": "mypass1234"
} );
- Create a new form for creating a Post. Ensure the user must be logged in to access it.
- Handle validation and saving of a new Post. On success, relocate to the posts.index page with a cbmessagebox message.
- Show the author's name on the Post card.
- Create a second Post from the UI. Notice the Posts are sorting in ascending order by id.
- Order the posts in descending order by createdDate.
Goal: Create a new Post from the UI
Break
15 Minutes
var user = getInstance( "User" )
.where( "username", rc.username )
.first();
var user = getInstance( "User" )
.where( "username", rc.username )
.firstOrFail();
var user = getInstance( "User" )
.find( rc.userID );
var user = getInstance( "User" )
.findOrFail( rc.userID );
Returns a single entity
- Create a new action for show to load the Post with the given id in the url.
- Display the post in a new posts.show view. Include a link back to the posts.index page.
- Link the title of each Post on the posts.index page to the new posts.show route.
- Handle the EntityNotFound exception thrown by Quick to show a 404 page.
Goal: Display a single Post
// Manually set fields and manually call `save`
var user = getInstance( "User" ).findOrFail( 1 );
user.setPassword( "newpassword" );
user.save();
// Set fields using `fill` and manually call `save`
var user = getInstance( "User" ).findOrFail( 1 );
user.fill( {
"password": "newpassword"
} );
user.save();
// Set fields and save entity all at once
var user = getInstance( "User" ).findOrFail( 1 );
user.update( {
"password" = "newpassword"
} );
// Update all entities that match a query
getInstance( "User" )
.where( "lastLoggedIn", ">", dateAdd( "m", 3, now() ) )
.updateAll( {
"active" = 0
} );
// Deleting a single entity
var user = getInstance( "User" ).findOrFail( 1 );
user.delete();
// Deleting all entities that match a query
getInstance( "User" )
.whereActive( false )
.deleteAll();
- Add an edit form. Ensure that only the author of a Post can view this form.
- Handle validation and updating of Posts. Ensure that only the author of a Post can edit the Post. Relocate back to the posts.show route.
- Add a link to delete a Post on the edit page when the logged in User is the author of the Post.
- Handle deleting a Post. Ensure that only the author of a Post can delete the Post. Relocate back to the posts.index route.
Goal: Allow for editing, updating, and deleting of Posts
See You Tomorrow!
component extends="quick.models.BaseEntity" accessors="true" {
function posts() {
return hasMany( "Post" );
// same as
// return hasMany( "Post", "userID", "id" );
}
}
var user = getInstance( "User" ).findOrFail( 1 );
var posts = user.getPosts(); // returns an array
var newPost = getInstance( "Post" ).fill( { /* ... */ } );
user.posts().save( newPost ); // sets the correct foreign keys and saves
// same as
user.posts().create( { /* ... */ } );
// Returns a relationship query
// can be further constrained using Quick and qb methods
var qb = user.posts();
- Create a migration to create a comments table.
- Create a Comment entity.
- Add a relationship from Comment to Post and from Comment to User.
- Add the inverse relationship from Post to Comment.
- Show the new comment form on the posts.show view when the user is logged in.
- Validate and create a Comment associated with a Post. (The User must be logged in to perform this action.)
- Show all Comments underneath a Post. Comments should display their commenter's name, the time it was posted, and the body of the Comment.
Goal: Add a Commenting System
var subscribedUsers = getInstance( "User" )
.where( "subscribed", true )
.get();
component extends="quick.models.BaseEntity" accessors="true" {
function scopeSubscribed( qb ) {
return qb.where( "subscribed", true );
}
}
var subscribedUsers = getInstance( "User" )
.subscribed()
.get();
var postsForUser = getInstance( "Post" )
.where( "userID", user.getID() )
.get();
var postsForUser = user.posts().get();
component extends="quick.models.BaseEntity" accessors="true" {
function posts() {
return hasMany( "Post" );
}
}
- Convert the orderByDesc( "createdDate" ) to a scope called latest.
- Hide the implementation detail of userId when creating a Post by utilizing the posts relationship on User.
- Remove the cbsecurity check by constraining the Posts queries to the logged in user's posts using the posts relationship.
Goal: Make existing code cleaner, more readable, and more expressive using Scopes and Relationships
Break
15 Minutes
We Have a Problem...
The Solution?
- Ensure you have at least 10 posts and that at least one of your posts has at least 5 comments in order to see the N+1 problem and the fix.
- Install cbdebugger.
- Load the posts.index view and check out the cbdebugger view. Notice that we are executing a bunch of queries for our single page.
- Eager load the author relationship for our posts.index route.
- Eager load the commenter relationship for the posts.show route.
Goal: Solve the N+1 problem with Eager Loading
component displayname="Post" extends="quick.models.BaseEntity" accessors="true" {
function tags() {
return belongsToMany( "Tag" );
// same as
return belongsToMany(
"Tag",
"posts_tags",
"postID",
"tagID",
"id", // posts.id
"id" // tags.id
);
}
}
component displayname="Tag" extends="quick.models.BaseEntity" accessors="true" {
function posts() {
return belongsToMany( "Post" );
}
}
// attach
var post = getInstance( "Post" ).findOrFail( 1 );
var tag = getInstance( "Tag" ).create( { "name": "miscellaneous" });
post.tags().attach( tag.getId() ); // pass an id (or array of ids)
post.tags().attach( tag ); // or pass an entity (or array of entities)
// detach
var post = getInstance( "Post" ).findOrFail( 1 );
var tag = getInstance("Tag").firstWhere( "name", "miscellaneous" );
post.tags().detach( tag.getId() ); // pass an id (or array of ids)
post.tags().detach( tag ); // or pass an entity (or array of entities)
// sync
var post = getInstance( "Post" ).findOrFail( 1 );
post.tags().sync( [ 2, 3, 6 ] ); // or an array of entities
- Create a migration to add a tags table. Populate it with some pre-made tags.
- Create a Tag entity.
- Create a migration for the posts_tags pivot table between posts and tags
- Show a multiple select field on the posts.new form with all the tags.
- Validate and sync tags when creating the new Post.
- Show associated tags on the posts.index view.
- Show associated tags on the posts.show view.
- Show a multiple select field on the posts.edit form with all the tags. The currently associated tags should be pre-selected.
- Validate and sync tags when updating the Post.
- Make sure to eager load the tags on the posts.index view.
Goal: Allow adding tags to posts
Lunch
1 hour
component displayname="User" extends="quick.models.BaseEntity" accessors="true" {
property name="id";
property name="username";
property name="email";
property name="password";
property name="createdDate";
property name="modifiedDate";
}
(Usage in an APIs)
var memento = user.getMemento();
/*
{
"id" = 1,
"username" = "JaneDoe",
"email" = "jane@example.com",
"password" = "$2a$04$2nVI5rPOfl6.hrflkhBWOObO5Z7lXGJpi1vlosY74NrL/CKdpWqZS"
"createdDate" = "{ts '2018-03-12 16:14:10'}",
"modifiedDate" = "{ts '2018-03-12 16:14:10'}"
}
*/
Serialization
component displayname="User" extends="quick.models.BaseEntity" accessors="true" {
property name="id";
property name="username";
property name="email";
property name="password";
property name="createdDate";
property name="modifiedDate";
}
var memento = user.getMemento(
excludes = [ "password" ]
);
/*
{
"id" = 1,
"username" = "JaneDoe",
"email" = "jane@example.com",
"createdDate" = "{ts '2018-03-12 16:14:10'}",
"modifiedDate" = "{ts '2018-03-12 16:14:10'}"
}
*/
Serialization
var users = getInstance( "User" )
.asMemento( excludes = [ "password" ] )
.get()
/*
[
{
"id" = 1,
"username" = "JaneDoe",
"email" = "jane@example.com",
"createdDate" = "{ts '2018-03-12 16:14:10'}",
"modifiedDate" = "{ts '2018-03-12 16:14:10'}"
},
{
"id" = 2,
"username" = "JohnDoe",
"email" = "john@example.com",
"createdDate" = "{ts '2018-03-12 16:14:10'}",
"modifiedDate" = "{ts '2018-03-12 16:14:10'}"
},
...
]
*/
- Create a migration for a likes table. This is our pivot table between users and posts.
- Create an entity for a Like. Define the necessary relationships between Like, Post, and User.
- Create a route, handler, and action to save a User's like of a Post. This endpoint should return a memento of the Like entity with a 201 Created status code.
- Add a route and action to remove a User's like of a Post. This endpoint should return nothing with a 204 No Content status code.
- Add a thumbs up "Like" button to the bottom of the posts.show page. The button should show selected if the logged in user has liked the post.
- Wire up the button to hit the correct API endpoint when clicked.
Goal: Allow async liking of posts
Subselects
var post = getInstance( "Post" )
.addSubselect( "authorEmail", function( qb ) {
return qb.select( "email" )
.from( "users" )
.whereColumn( "users.id", "posts.userID" );
} )
.firstOrFail();
post.getAuthorEmail(); // john@example.com
component displayname="Post" extends="quick.models.BaseEntity" accessors="true" {
property name="id";
property name="title";
property name="body";
property name="createdDate";
property name="modifiedDate";
function author() {
return belongsTo( "User" );
}
}
var post = getInstance( "Post" )
.addSubselect( "authorEmail", "author.email" )
.firstOrFail();
post.getAuthorEmail(); // john@example.com
component displayname="User" extends="quick.models.BaseEntity" accessors="true" {
property name="id";
property name="email";
property name="createdDate";
property name="modifiedDate";
}
Relationship Counts
var post = getInstance( "Post" )
.addSubselect( "commentsCount", function( qb ) {
qb.selectRaw( "COUNT(*)" )
.from( "comments" )
.whereColumn( "comments.postID", "posts.id" );
} )
.firstOrFail();
post.getCommentsCount(); // 5
Relationship Counts
component displayname="Post" extends="quick.models.BaseEntity" accessors="true" {
property name="id";
property name="title";
property name="body";
property name="createdDate";
property name="modifiedDate";
function comments() {
return hasMany( "Comment" );
}
}
var post = getInstance( "Post" )
.withCount( [ "comments" ] )
.firstOrFail();
post.getCommentsCount(); // 5
- Replace eager loading the author with an authorName subselect on posts.index.
- Show the total number of comments for each post on the posts.index page.
- Show the total number of comments on the posts.show page in the Comments header.
- Show the number of likes for each post on the posts.index page.
- Show the number of likes on the like button on the posts.show page.
Goal: Reduce queries with subselects and relationship counts
Debugging with Quick
var sql = getInstance( "Post" )
.latest()
.toSQL();
/*
SELECT *
FROM `posts`
ORDER BY `posts`.createdDate` DESC
*/
Debugging with Quick
var posts = getInstance( "Post" )
.dump( label = "before latest scope" )
.latest()
.dump( label = "after latest scope" )
.get();
Debugging with Quick
Debugging with Quick
// config/ColdBox.cfc
component {
function configure() {
logbox = {
debug = [ "qb.models.Grammars" ]
};
}
}
Testing with Quick
- Integration Testing
- Testing Database
- Automatic Migrations and Resets
- Running Tests in Transactions
- Factories vs Fixtures
- Create a test database.
- Configure your application to run tests with the test database as the default datasource.
- Create a failing test case for visiting an existing author profile page. The following expectations should be met:
- It should be reached by visiting /authors/:authorID.
- It should show only the posts written by that author.
- When working TDD, try to only do the least amount of work needed to make the next different error appear. Try to guess the next error you will see before re-running the test.
- Once the test is passing, look for opportunities to refactor.
- Fill out the new author-profiles/show view.
- Link the author names from the posts.index and posts.show pages to the new author profile pages.
Goal: Utilize TDD to create an Author Profile page
Break
15 Minutes
- Backfill a test around the posts.index route.
- Add a failing test showing that posts with a scheduled date in the future do not appear on the page. Let this drive the implementation.
- Add the scheduled publishing feature.
- Now that your test is passing, you can refactor.
- Add a failing test showing that unpublished posts route to the 404 page when trying to view them directly.
- Implement this feature using TDD.
- Add the new field to your posts.new and posts.edit forms.
- Capture the optional published date in your handler.
- Verify it all works as expected in your app.
Goal: Add the ability to schedule posts to publish in the future using TDD
Q&A
Resources
Up and Running with Quick
By Eric Peterson
Up and Running with Quick
- 904