Archive for November, 2023


Building search forms with tableless models in CFWheels

This blog article was originally posted on Chris’ personal blog and is republished here with his permission.

In this post, I hope to persuade you that you will rarely ever need the Tag-based form helpers (textFieldTagselectTag, etc.) in your CFWheels apps ever again.

“How?” you ask.

The answer: through the use of a wonderful feature that we affectionately call tableless models.

How you’re probably used to coding search forms in CFWheels

So let’s code up an index form using the Tag-based helpers that you’re probably accustomed to using in this situation. The view’s job is to display a list of invoice records along with a form for narrowing by start date and end date:

<cfoutput>

#startFormTag(route="invoices", method="get")#
  #textFieldTag(name="startDate", value=params.startDate)#
  #textFieldTag(name="endDate", value=params.endDate)#
  #submitTag(value="Filter Invoices")#
#endFormTag()#

<table>
  <thead>
    <tr>
      <th>Invoice</th>
      <th>Date</th>
      <th>Amount</th>
    </tr>
  </thead>
  <tbody>
    <cfloop query="invoices">
      <tr>
        <td>#h(id)#</td>
        <td>#DateFormat(createdAt)#</td>
        <td>#DollarFormat(amount)#</td>
      </tr>
    </cfloop>
  </tbody>
</table>

</cfoutput>

This is pretty common, and I wouldn’t go as far to say that it’s wrong.

Let’s code what we need in the controller to wire everything up.

component extends="Controller" {
  function index() {
    param name="params.startDate" default="";
    param name="params.endDate" default="";
    
    local.where = [];
    
    if (IsDate(params.startDate)) {
      ArrayAppend(local.where, "createdAt >= '#params.startDate#'");
    }
    
    if (IsDate(params.endDate)) {
      local.nextDay = DateAdd("d", 1, params.endDate);
      local.nextDay = DateFormat(local.nextDay, "m/d/yyyy");
      ArrayAppend(local.where, "createdAt < '#local.nextDay#'");
    }
    
    invoices = model("invoice").findAll(where=ArrayToList(local.where, " AND "));
  }
}

But wait! We can’t have a startDate that occurs after the endDate. We better add a check for that in the controller:

component extends="Controller" {
  function index() {
    param name="params.startDate" default="";
    param name="params.endDate" default="";
    
    local.where = [];
    
    // Let's make sure the start date and end date jive.
    if (IsDate(params.startDate) && IsDate(params.endDate) && params.startDate > params.endDate) {
      flashInsert(error="The start date must be on or before the end date.");
    }
    
    if (IsDate(params.startDate)) {
      ArrayAppend(local.where, "createdAt >= '#params.startDate#'");
    }
    
    if (IsDate(params.endDate)) {
      local.nextDay = DateAdd("d", 1, params.endDate);
      local.nextDay = DateFormat(local.nextDay, "m/d/yyyy");
      ArrayAppend(local.where, "createdAt < '#local.nextDay#'");
    }
    
    invoices = model("invoice").findAll(where=ArrayToList(local.where, " AND "));
  }
}

That index action is getting pretty beefy at this point. And now we’re starting to validate our data in the controller, which can quickly turn into a tangled mess after we’ve added another field or two to the form.

Cleaning up the search form with tableless models

As it turns out, models in CFWheels come with a bunch of really helpful methods for validating data. And even though we’re not using this form to save data to a database, we can still use the model validations to validate our data. Hooray!

All that we need to do is create a CFC in our models folder that represents this particular form. The initializer will contain a call to table(false), which tells CFWheels to not try to connect it to a database.

In addition to table(false), we can call all of the model validation initializers that we need to validate the data.

Lastly, we need to create a method that validates data passed into the model and runs the query if all is well.

Here is the finished product in models/InvoiceSearchForm.cfc:

component extends="Model" {
  function config() {
    // Make it tableless
    table(false);
    
    // Validations
    validatesFormatOf(properties="startDate,endDate", type="date", allowBlank=true);
    validate("startDateBeforeEndDateValidation");
  }
  
  boolean function run() {
    // Run validations and abort if failed.
    if (!this.valid()) {
      this.results = QueryNew("");
      return false;
    }
    
    // Continue with query if validation passed.
    local.where = [];
    
    if (IsDate(this.startDate)) {
      ArrayAppend(local.where, "createdAt >= '#this.startDate#'");
    }
    
    if (IsDate(this.endDate)) {
      local.nextDay = DateAdd("d", 1, this.endDate);
      local.nextDay = DateFormat(local.nextDay, "m/d/yyyy");
      ArrayAppend(local.where, "createdAt < '#local.nextDay#'");
    }
    
    this.results = model("invoice").findAll(where=ArrayToList(local.where, " AND "));
    return true;
  }
  
  private function startDateBeforeEndDateValidation() {
    if (IsDate(this.startDate) && IsDate(this.endDate) && this.startDate > this.endDate) {
      this.addError("startDate", "Start Date must be on or before End Date");
    }
  }
}

Notice that startDate and endDate become properties on the model in the this scope. This allows us to validate those properties and refer to them in an object-oriented manner.

When the run method is called, there will be a results property set on the object containing the search query.

Next, we rewire the controller to this much simpler form:

component extends="Controller" {
  function index() {
    // Note that moving this into an object named `search` will change the
    // `params` struct slightly.
    param name="params.search.startDate" default="";
    param name="params.search.endDate" default="";
    
    // We pass the `params.search` struct in as properties on the search form
    // object.
    search = model("invoiceSearchForm").new(argumentCollection=params.search);
    
    // This runs the search and adds an error message if validation fails.
    if (!search.run()) {
      flashInsert(error="There was an error with your search filters");
    }
  }
}

Much cleaner, huh? Excluding whitespace and comments, this reduces the contents of the index action from 16 lines of actual code to 5.

This methodology also improves the view because we can now use textField instead of textFieldTag, and we can display validation errors near the affected form fields:

<cfoutput>

#startFormTag(route="invoices", method="get")#
  #textField(objectName="search", property="startDate")#
  #errorMessageOn(objectName="search", property="startDate")#
  
  #textField(objectName="search", property="endDate")#
  #errorMessageOn(objectName="search", property="endDate")#
  
  #submitTag(value="Filter Invoices")#
#endFormTag()#

<table>
  <thead>
    <tr>
      <th>Invoice</th>
      <th>Date</th>
      <th>Amount</th>
    </tr>
  </thead>
  <tbody>
    <cfloop query="search.results">
      <tr>
        <td>#h(id)#</td>
        <td>#DateFormat(createdAt)#</td>
        <td>#DollarFormat(amount)#</td>
      </tr>
    </cfloop>
  </tbody>
</table>

</cfoutput>

As a bonus, our InvoiceSearchForm model allows us to do things like set the labels/error message labels on the form fields using property, and allows us to do most of what models allow us to do: namely validations and callbacks.

component extends="Model" {
  function config() {
    table(false);
    
    // Set property labels for form fields and related error messages.
    property(name="startDate", label="Start");
    property(name="endDate", label="End");
    
    //...
  }

  //...
}

I find this to be a nice pattern because it ties the form to the model in a fairly clean, object-oriented way: the model represents the form, so it makes sense for it to define how labels on the form should appear.

Other uses for tableless models

Here are some other ideas where tableless models are a Good Idea™:

Authentication forms
The model takes care of all authentication logic.

Password change/reset forms
Move interface-based concepts like validatesConfirmationOf out of the table-based user model and into a tableless model.

Database transactions involving multiple models
Nested properties have their limits and logic related to them can really pollute your table-based models. Handle all of the logic in a model that’s intimately involved with the form.

Reports
Have you ever found yourself in a situation where you needed to run a query involving multiple database tables, but it was unclear which model to write the query in? Tableless models are a perfect way to avoid making a random decision.

NoSQL and API integration
Are you saving your data somewhere other than a relational database? You can still model the business logic using CFWheels models.

Props

Most of this inspiration came from a similar concept known as Form Objects in Ruby on Rails. (There is a great Railscast about Form Objectshere too.)

Also, major kudos to Tony Petruzzi for adding this awesome feature into CFWheels, which made its way into the v1.3 release.

CFWheels v2.5.0 Released

This is a major milestone release of CFWheels v2.5.0 that has been in the works for over a year. As you can see nearly 34 PRs have been merged into the codebase which include many enhancements and bug fixes. In addition many changes have been made to the tooling used in the project.

Here are some of the highlights:

  • We have begun to publish SNAPSHOTS to ForgeBox.io on each successful commit to the develop branch.
  • The GitHub Actions CI scripts use the same configuration files as the local Docker testing suite. If you are inclined to contribute to the CFWheels project you will most likely want to be able to run the test suite locally in Docker containers to test your changes before you submit a PR. To run the local test suite simply type docker compose up in the root of your project. The source code is injected into the containers dynamically so it makes it easier to make changes and see them appear in the docker containers without rebuilding the containers. Look for more details on this to come in the future.
  • Every commit is now tested across a matrix of 20 combinations of CF Engines and Databases. The matrix includes CF Engines (Lucee 5, Lucee 6, Adobe ColdFusion 2016, Adobe ColdFusion 2018, Adobe ColdFusion 2021, and Adobe ColdFusion 2023) and databases (H2, MS SQL Server, PostgreSQL, and MySQL).
  • Each successful commit automatically builds two packages on ForgeBox. One for the default template and one for the core CFWheels folder.

Upgrading an Existing Project

The changes in this version are confined to the wheels directory so simply swapping out your wheels directory should be all you need to do to upgrade.

Changelog

Model Enhancements

  • PR-1183-Allow datasource argument in finders #1183 – [Adam Chapman]
  • PR-1201-Issue ORM create() fails object validation for not null columns with defaults #929 validate not nullable columns with default #1201 – [Adam Chapman]
  • PR-1202-Remove old oracle test workaround #1202 – [Adam Chapman]
  • PR-1205-issue-1182-adds-simplelock-to-sql-caching #1205 – [Adam Chapman]
  • PR-1222-Findall() performance bottleneck #1222 – [Adam Chapman]
  • PR-1223-refactor-queryCallback-with-inbuilt-query-functions #1223 – [Adam Chapman]
  • PR-1226-Invalid column not throwing exception in select argument #1226 – [Zain Ul Abideen]
  • PR-1265-improve-performance-refactor-out-listfind #1265 – [Adam Chapman]
  • PR-1260-Adds support for native query returnType #1260 – [Adam Chapman]
  • PR-1249-Removed the original IF/ELSE condition that invalidates calculated props and added condition #1240 – [Zain Ul Abideen]

View Enhancements

  • PR-1254-issue 908 enable paginationLinks() to set active class on parent #1254 – [Zain Ul Abideen]

Bug Fixes

  • PR-1227-Return a numeric value if the primary key is Numeric #1227 – [Zain Ul Abideen]
  • PR-1257-Checkbox bug when checkedvalue is not true #1257 – [Adam Chapman]
  • PR-1246-set the default route if it is not passed in the function #1246 – [Zain Ul Abideen]
  • PR-1256-issue 889 unable to duplicate component #1256 – [Zain Ul Abideen]
  • PR-1253-Issue 580 select ambiguous column name using the wheels alias #1253 – [Zain Ul Abideen]
  • PR-1245-Added afterFind callback hook in the findAll function in case of structs #1245 – [Zain Ul Abideen]
  • PR-1302-Check for Reload Password when setting a url IP exception #1302 – Peter Amiri

Miscellaneous

  • PR-1175-restoreTestRunnerApplicationScope setting #1175 – [Adam Chapman]
  • PR-1176-fix text in core readme file #1176 – [Per Djurner]
  • PR-1177-fix text in base template readme file #1177 – [Per Djurner]
  • PR-1178-fix text in default template file #1178 – [Per Djurner]
  • PR-1185-adds-root-docker-volume #1185 – [Adam Chapman]
  • PR-1200-Update the docker-compose command to docker compose v2 syntax #1200 – [Adam Chapman, Peter Amiri]
  • PR-1204-Add Lucee 6 to test matrix on local Docker test suite #1204 – [Peter Amiri]
  • PR-1203-ensure testing params maintained #1203 – [Adam Chapman]
  • PR-1228-Adding addClass attribute in the function textField #1228 – [Zain Ul Abideen]
  • PR-1230-Add Adobe 2021 Support to local Docker and GitHub Actions testing – #1230 – Peter Amiri
  • PR-1264-update Lucee 6 version used for tests to latest #1264 – [Zac Spitzer – * New Contributor *]
  • PR-1241-Fix spelling and remove whitespace from link #1241 – [John Bampton]
  • PR-1247-show the current git branch in the debug layout #1247 – [Michael Diederich]
  • PR-1250-Added test framework functions in the docs #1250 – [Zain Ul Abideen]
  • PR-1255-issue 1179 Downloaded the CDN files and changed paths in files #1255 – [Zain Ul Abideen]

Guides

  • PR-1198-Documentation-fixes #1198 – [Adam Chapman]

Download Zip File