Groovy Tabular Data DSL

After using Spock, I became really interested in the tabular input of data into my services, not just into my test. The project I'm working on has a lot of transaction data with various associated properties. So transaction type 1, has fee type A associated with it.

Traditionally I've done that type of property mapping using enums, or maybe a switch statement.This works well, but there's still a good amount of code, and I'd like to make the mapping as readable as possible for the next guy. Especially since in the particular case I'm working on, the enum has some 700+ transaction types and only a handful of them need additional properties.

Groovy made property mapping easier with it's slick map syntax, and that worked really well too, but I kept thinking about Spock, and that tabular data syntax, which I thought was even more readable than a map. I had this:

[Transaction.SERVICE_CHARGE : 
  [feeType: FeeType.SERVICE_FEE,
   waiveTransaction : Transaction.SERVICE_CHARGE_WAIVED,
   reapplyTransaction : Transaction.SERVICE_CHARGE_REAPPLIED
   desc: 'PAID SERVICE CHARGE'
  ],
Transaction.NSF_FEE_CHARGE : 
  [feeType: FeeType.NSF_FEE,
   waiveTransaction : Transaction.NSF_WAIVED,
   reapplyTransaction : Transaction.NSF_REAPPLIED
   desc: 'NSF CHARGE'
  ]
]

But I was really hoping to see something more like this (values omitted for formatting):

transType                  | feeType             | desc
Transaction.SERVICE_CHARGE | FeeType.SERVICE_FEE | 'PAID SERVICE CHARGE'
Transaction.NSF_FEE_CHARGE | FeeType.NSF_FEE     | 'NSF CHARGE'

During my research I ran across a blog entry by Christian Baranowski that did an excellent job of showing how to use Groovy's category and override properties features to create a simple table DSL. I took what he did and added a few more features for my own benefit, so my version supports three different outputs:

* data - returns a  list of lists, just giving the values from the table
* dataWithTitleRow - returns a list of mapped properties that represent each row, the title row column names are used as the key in the map
* dataWithTitleRow (def key) - returns a keyed map using the value from the value from the column identified from the 'key' parameter as the key.

def data = Table.withTitleRow('transType') {
transType                  | feeType             | desc
Transaction.SERVICE_CHARGE | FeeType.SERVICE_FEE | 'PAID SERVICE CHARGE'
Transaction.NSF_FEE_CHARGE | FeeType.NSF_FEE     | 'NSF CHARGE'
}
assert data[Transaction.SERVICE_CHARGE].desc == 'PAID SERVICE CHARGE'

Here's what my version looks like

import groovy.transform.Canonical

/**
 * Parses tubular data into 'rows' of just values, or mapped values (if there
 * is a title row in the data), or a map of keyed values.
 *
 * <p>
 * Based on blog entry at
 * http://tux2323.blogspot.com/2013/04/simple-table-dsl-in-groovy.html
 */
public class Table {
  /**
   * The Groovy category feature used to implement this DSL uses static
   * methods, so thread local is used to store off the parsed content as it
   * is processed.
   */
  private static ThreadLocal<List> PARSING_CONTEXT = new ThreadLocal<List>()

  /**
   * Parses a list of tabular data.
   *
   * <pre>
   * def data = Table.data {
   * Donald | Duck | Mallard
   * Mickey | Mouse | Rodent
   * }
   * assert data.first() == ['Donald', 'Duck', 'Mallard']
   * </pre>
   *
   * @param tabularData contains all the data to parse delimited by |
   * @return a list of lists of data
   */
  public static List data(Closure tabularData) {
    PARSING_CONTEXT.set([])
    use(Table) {
      tabularData.delegate = new PropertyVarConvertor()
      tabularData.resolveStrategy = Closure.DELEGATE_FIRST
      tabularData()
    }
    return PARSING_CONTEXT.get().collect { Row row -> row.values }
  }

  /**
   * Parses a list of tabular data with a title row, returns a list of
   * mapped properties.
   *
   * <pre>
   * def data = Table.dataWithTitleRow {
   * firstName | lastName | type
   * Donald | Duck | Mallard
   * Mickey | Mouse | Rodent
   * }
   * assert data.first() ==
   *   [firstName: 'Donald', lastName: 'Duck', type: 'Mallard']
   * </pre>
   *
   * @param tabularData contains all the data to parse delimited by |
   * @return a list of lists of data
   */
  public static List dataWithTitleRow(Closure tabularData) {
    List rows = data(tabularData)
    def titleRow = rows.first()

    return rows[1..<rows.size()].collect { List row ->
      def mappedRows = [:]
      row.eachWithIndex { it, index ->
        mappedRows[titleRow[index]] = it
      }
      return mappedRows
    }
  }

  /**
   * Parses a list of tabular data with a title row and specifies the column
   * that should be used as a key in the output map.
   *
   * <pre>
   * def data = Table.dataWithTitleRow('firstName') {
   * firstName | lastName | type
   * Donald | Duck | Mallard
   * Mickey | Mouse | Rodent
   * }
   * assert data['Donald'] ==
   *   [firstName: 'Donald', lastName: 'Duck', type: 'Mallard']
   * </pre>
   *
   * @param key the name of the column that should be used as the key in
   * the output map
   * @param tabularData contains all the data to parse delimited by |
   * @return a list of lists of data
   */
  public static Map dataWithTitleRow(def key, Closure tabularData) {
    Map keyed = [:]
    dataWithTitleRow(tabularData).each {
      keyed[it[key]] = it
    }
    return keyed
  }

  /**
   * Groovy treats a new line as the end of statement (with some exceptions),
   * so each new line will invoke this method which creates a new table row
   * which then is used to 'or' each value in the row together.
   *
   * @param self the left argument in the OR operator, the current value
   * @param arg the right argument in the OR operator, the next value
   *
   * @return a reference to the next argument, so that it can 'or-ed' against.
   */
  public static Row or(self, arg) {
    def row = new Row([self])
    PARSING_CONTEXT.get().add(row)
    return row.or(arg)
  }

  /**
   * Implements the 'or' operator to append each value in a row to a list.
   * Returns a reference to itself so the next or operation can append the
   * next value.
   */
  @Canonical
  static class Row {
    List values = []
    def Row or(arg) {
      values << arg
      return this
    }
  }

  /**
   * Handler to treat any properties that cannot be found as strings.
   */
  static class PropertyVarConvertor {
    def getProperty(String property) {
      return property
    }
  }
}

Post a Comment