1. Code
  2. WordPress
  3. Plugin Development

Custom Database Tables: Creating an API

In the first part of this series we looked at the disadvantages of using a custom table. One of the major ones is the lack of an API: so in this article we'll look at how to create one. The API acts a layer between handling data in your plug-in and the actual interaction with the database table – and is primarily intended to ensure such interactions are safe and to provide a ‘human friendly’ wrapper for your table. As such we’ll require wrapper functions for inserting, updating, deleting and querying data.
Scroll to top
This post is part of a series called Custom Database Tables.
Custom Database Tables: Safety First
Custom Database Tables: Maintaining the Database

In the first part of this series we looked at the disadvantages of using a custom table. One of the major ones is the lack of an API: so in this article we'll look at how to create one. The API acts a layer between handling data in your plug-in and the actual interaction with the database table – and is primarily intended to ensure such interactions are safe and to provide a ‘human friendly’ wrapper for your table. As such we’ll require wrapper functions for inserting, updating, deleting and querying data.


Why Should I Create an API?

There are several reasons why an API is recommended – but most boil down to two related principles: reducing code duplication and separation of concerns.

It’s Safer

With the above four mentioned wrapper functions you only need to ensure your database queries are safe in four places – you can then forget about sanitisation completely. Once your confident that your wrapper functions handle the database safely, then you do not need to worry about the data you’re giving them. You can also validate the data – returning an error if something isn’t right.

The idea is that without these function you’ll need to make sure each instance of interacting with your database does so safely. This just brings an increased likelihood that in one of these instances you’ll miss something and create a vulnerability in your plug-in.

Reduces Bugs

This is related to the first point (and both are related to code duplication). By duplicating code there is greater scope for bugs to crawl in. Conversely, by using a wrapper functions – if there is a bug with updating or querying the database table – you know exactly where to look.

It’s Easier to Read

This may seem like a ‘soft’ reason – but readability of code is incredibly important. Readability is about making the logic and actions of the code clear to the reader. This isn’t just important when working as a part of a team, or when someone might inherit your work: you may know what your code is meant to do now, but in six months you will probably have forgotten. And if your code is hard to follow, its easier to introduce a bug.

Wrapper functions clean up your code by literally separating the internal workings of some operation (say creating a post) from the context of that operation (say handling a form submission). Just imagine having the entire contents of wp_insert_post() in place of every instance you use wp_insert_post().

Adds a Layer of Abstraction

Adding layers of abstraction is not always a good thing – but here it undoubtedly is. Not only do these wrappers provide a human friendly way of updating or querying the table (imagine having to use SQL to query posts rather than using the far more concise WP_Query() – and all the SQL formulation and sanitisation that goes with it), but also helps protects you and other developers from changes to the underlying database structure.

By using wrapper functions you, as well as third parties, can use these without fear that they are unsafe or will break. If you decide to rename a column, move a column elsewhere or even delete it you can be sure that the rest of your plug-in won’t break, because you just make the necessary changes to your wrapper functions. (Incidentally this is a compelling reason to avoid direct SQL queries of WordPress tables: if they change, and they will, it will break your plug-in.). On the other hand an API help your plug-in to be extended in a stable way.

Consistency

I'm perhaps guilty of splitting one point into two here - but I feel that this an important benefit. There is little worse than inconsistency when developing plug-ins: it just encourages messy code. Wrapper functions provide a consistent interaction with the database: you provide data and it returns true (or an ID) or false (or an WP_Error object, if you prefer).


The API

Hopefully I've now convinced of the need for an API for your table. But before we go any further we’ll first define a helper function that will make sanitisation a bit easy.

The Table Columns

We’ll define a function which returns the columns of table along with the data format they expect. By doing this we can easily whitelist allowed columns and format the input accordingly. Furthermore if we make any changes to the columns, we just need to make the changes here

1
2
function wptuts_get_log_table_columns(){
3
    return array(
4
        'log_id'=> '%d',
5
        'user_id'=> '%d',
6
        'activity'=>'%s',
7
        'object_id'=>'%d',
8
        'object_type'=>'%s',
9
        'activity_date'=>'%s',
10
    );
11
}

Inserting Data

The most basic ‘insert’ wrapper function will just take an array of column-value pairs and insert these to the database. This need not be the case: you may decide to provide more ‘human friendly’ keys which you then map to the column names. You may also decide that some values are auto-generated or over-ridden based on the passed values (for example: post status in wp_insert_post()).

It maybe the *values* that need mapping. The format that data is best stored in is not always the most convenient format to use. For example, for dates it maybe easier to handle a DateTime object or a timestamp - and then convert this to the desired date format.

The wrapper function may be simple or complicated – but the minimum it should do is sanitise the input. I’d also recommend whitelisting for the recognised columns, as trying to insert data into a column that doesn’t exist can throw an error.

In this example the user ID is by default that of the current user, and all fields are given by their column name - which the exception of the activity date which is passed as 'date'. The date, in this example, should be a local timestamp, which is converted prior to adding it to the database.

1
2
/**

3
 * Inserts a log into the database

4
 *

5
 *@param $data array An array of key => value pairs to be inserted

6
 *@return int The log ID of the created activity log. Or WP_Error or false on failure.

7
*/
8
function wptuts_insert_log( $data=array() ){
9
    global $wpdb;        
10
11
    //Set default values

12
    $data = wp_parse_args($data, array(
13
                 'user_id'=> get_current_user_id(),
14
                 'date'=> current_time('timestamp'),
15
    ));    
16
17
    //Check date validity

18
    if( !is_float($data['date']) || $data['date'] <= 0 )
19
        return 0;
20
21
    //Convert activity date from local timestamp to GMT mysql format

22
    $data['activity_date'] = date_i18n( 'Y-m-d H:i:s', $data['date'], true );
23
24
    //Initialise column format array

25
    $column_formats = wptuts_get_log_table_columns();
26
27
    //Force fields to lower case

28
    $data = array_change_key_case ( $data );
29
30
    //White list columns

31
    $data = array_intersect_key($data, $column_formats);
32
33
    //Reorder $column_formats to match the order of columns given in $data

34
    $data_keys = array_keys($data);
35
    $column_formats = array_merge(array_flip($data_keys), $column_formats);
36
37
    $wpdb->insert($wpdb->wptuts_activity_log, $data, $column_formats);
38
39
    return $wpdb->insert_id;
40
}
Tip: It's also a good idea to check the validity of the data. What checks you should perform, and how the API reacts, depends entirely on your context. wp_insert_post(), for instance requires a certain degree of uniqueness to post slugs - if there's clashes, it auto-generates a unique one. wp_insert_term on the other hand returns an error if the term already exists. This is down to a mix between how WordPress handles these objects and semantics.

Updating Data

Updating data usually closely mimics inserting data – with the exception that a row identifier (usually just the primary key) is provided along with data that needs to be updated. In general the arguments should match the insert function (for consistency) - so in this example, 'date' is used instead of 'activity_date'

1
2
/**

3
 * Updates an activity log with supplied data

4
 *

5
 *@param $log_id int ID of the activity log to be updated

6
 *@param $data array An array of column=>value pairs to be updated

7
 *@return bool Whether the log was successfully updated.

8
*/
9
function wptuts_update_log( $log_id, $data=array() ){
10
    global $wpdb;        
11
12
    //Log ID must be positive integer

13
    $log_id = absint($log_id);     
14
    if( empty($log_id) )
15
         return false;
16
17
    //Convert activity date from local timestamp to GMT mysql format

18
    if( isset($data['activity_date']) )
19
         $data['activity_date'] = date_i18n( 'Y-m-d H:i:s', $data['date'], true );
20
21
22
    //Initialise column format array

23
    $column_formats = wptuts_get_log_table_columns();
24
25
    //Force fields to lower case

26
    $data = array_change_key_case ( $data );
27
28
    //White list columns

29
    $data = array_intersect_key($data, $column_formats);
30
31
    //Reorder $column_formats to match the order of columns given in $data

32
    $data_keys = array_keys($data);
33
    $column_formats = array_merge(array_flip($data_keys), $column_formats);
34
35
    if ( false === $wpdb->update($wpdb->wptuts_activity_log, $data, array('log_id'=>$log_id), $column_formats) ) {
36
         return false;
37
    }
38
39
    return true;
40
}

Querying Data

A wrapper function for querying data will often be fairly complicated – particularly since you may want to support all types of queries which select only certain fields, restrict by AND or OR statements, order by one of several possible columns etc (just see the WP_Query class).

The basic principal of wrapper function for querying the data is that should it should take a ‘query array’, interpret it, and form the corresponding SQL statement.

1
2
/**

3
 * Retrieves activity logs from the database matching $query.

4
 * $query is an array which can contain the following keys:

5
 *

6
 * 'fields' - an array of columns to include in returned roles. Or 'count' to count rows. Default: empty (all fields).

7
 * 'orderby' - datetime, user_id or log_id. Default: datetime.

8
 * 'order' - asc or desc

9
 * 'user_id' - user ID to match, or an array of user IDs

10
 * 'since' - timestamp. Return only activities after this date. Default false, no restriction.

11
 * 'until' - timestamp. Return only activities up to this date. Default false, no restriction.

12
 *

13
 *@param $query Query array

14
 *@return array Array of matching logs. False on error.

15
*/
16
function wptuts_get_logs( $query=array() ){
17
18
     global $wpdb;
19
     /* Parse defaults */
20
     $defaults = array(
21
       'fields'=>array(),'orderby'=>'datetime','order'=>'desc', 'user_id'=>false,
22
       'since'=>false,'until'=>false,'number'=>10,'offset'=>0
23
     );
24
25
    $query = wp_parse_args($query, $defaults);
26
27
    /* Form a cache key from the query */
28
    $cache_key = 'wptuts_logs:'.md5( serialize($query));
29
    $cache = wp_cache_get( $cache_key );
30
31
    if ( false !== $cache ) {
32
            $cache = apply_filters('wptuts_get_logs', $cache, $query);
33
            return $cache;
34
    }
35
36
     extract($query);
37
38
    /* SQL Select */
39
    //Whitelist of allowed fields

40
    $allowed_fields = wptuts_get_log_table_columns();
41
	
42
    if( is_array($fields) ){
43
	    //Convert fields to lowercase (as our column names are all lower case - see part 1)

44
	    $fields = array_map('strtolower',$fields);
45
46
    	    //Sanitize by white listing

47
    	   $fields = array_intersect($fields, $allowed_fields);
48
	}else{
49
		$fields = strtolower($fields);
50
	}
51
52
    //Return only selected fields. Empty is interpreted as all

53
    if( empty($fields) ){
54
        $select_sql = "SELECT* FROM {$wpdb->wptuts_activity_log}";
55
    }elseif( 'count' == $fields ) {
56
        $select_sql = "SELECT COUNT(*) FROM {$wpdb->wptuts_activity_log}";
57
    }else{
58
        $select_sql = "SELECT ".implode(',',$fields)." FROM {$wpdb->wptuts_activity_log}";
59
    }
60
61
     /*SQL Join */
62
     //We don't need this, but we'll allow it be filtered (see 'wptuts_logs_clauses' )

63
     $join_sql='';
64
65
    /* SQL Where */
66
    //Initialise WHERE

67
    $where_sql = 'WHERE 1=1';
68
69
    if( !empty($log_id) )
70
       $where_sql .=  $wpdb->prepare(' AND log_id=%d', $log_id);
71
72
    if( !empty($user_id) ){
73
74
       //Force $user_id to be an array

75
       if( !is_array( $user_id) )
76
           $user_id = array($user_id);
77
78
       $user_id = array_map('absint',$user_id); //Cast as positive integers

79
       $user_id__in = implode(',',$user_id);
80
       $where_sql .=  " AND user_id IN($user_id__in)";
81
    }
82
83
    $since = absint($since);
84
    $until = absint($until);
85
86
    if( !empty($since) )
87
       $where_sql .=  $wpdb->prepare(' AND activity_date >= %s', date_i18n( 'Y-m-d H:i:s', $since, true));
88
89
    if( !empty($until) )
90
       $where_sql .=  $wpdb->prepare(' AND activity_date <= %s', date_i18n( 'Y-m-d H:i:s', $until, true));
91
92
    /* SQL Order */
93
    //Whitelist order

94
    $order = strtoupper($order);
95
    $order = ( 'ASC' == $order ? 'ASC' : 'DESC' );
96
97
    switch( $orderby ){
98
       case 'log_id':
99
            $order_sql = "ORDER BY log_id $order";
100
       break;
101
       case 'user_id':
102
            $order_sql = "ORDER BY user_id $order";
103
       break;
104
       case 'datetime':
105
             $order_sql = "ORDER BY activity_date $order";
106
       default:
107
       break;
108
    }
109
110
    /* SQL Limit */
111
    $offset = absint($offset); //Positive integer

112
    if( $number == -1 ){
113
         $limit_sql = "";
114
    }else{
115
         $number = absint($number); //Positive integer

116
         $limit_sql = "LIMIT $offset, $number";
117
    }
118
119
    /* Filter SQL */
120
    $pieces = array( 'select_sql', 'join_sql', 'where_sql', 'order_sql', 'limit_sql' );
121
    $clauses = apply_filters( 'wptuts_logs_clauses', compact( $pieces ), $query );
122
    foreach ( $pieces as $piece )
123
          $$piece = isset( $clauses[ $piece ] ) ? $clauses[ $piece ] : '';
124
125
    /* Form SQL statement */
126
    $sql = "$select_sql $where_sql $order_sql $limit_sql";
127
128
    if( 'count' == $fields ){
129
        return $wpdb->get_var($sql);
130
    }
131
132
    /* Perform query */
133
    $logs = $wpdb->get_results($sql);
134
135
    /* Add to cache and filter */
136
    wp_cache_add( $cache_key, $logs, 24*60*60 );
137
    $logs = apply_filters('wptuts_get_logs', $logs, $query);
138
    return $logs;
139
 }

There is a fair bit going in the above example as I’ve tried to include several features that might be considered when developing your wrapper functions, which we cover in the subsequent sections.

Cache

You may consider your queries to be sufficiently complex, or repeated regularly, that it makes sense to cache the results. Since different queries will return different results, we obviously don’t want to use a generic cache key – we need one which is unique to that query. This is exactly what the following does. It serialises the query array, and then hashes it, producing a key unique to $query:

1
2
 $cache_key = 'wptuts_logs:'.md5( serialize($query));

Next we check if we have anything stored for that cache key – if so, great, we just return its contents. If not, we generate the SQL, perform the query and then add the results to the cache (for at most 24 hours) and return them. We'll need to remember that records could take up to 24 hours to appear in the results from this function. Usually there are contexts where the cache is automatically cleared - but we would need to implement these.

Filters & Actions

Hooks have been covered extensively on WPTuts+ recently by Tom McFarlin and Pippin Williamson. In his article, Pippin talks about the reasons why you should make your code extensible through hooks, and wrappers such as wptuts_get_logs() serve as excellent examples of where they can be used.

We’ve used two filters in the above function:

  • wptuts_get_logs – filters the result of the function
  • wptuts_logs_clauses – filters an array of SQL components

This allows third-party developers, or even ourselves, to build on the provided API. If we avoid using direct SQL in our plug-in and only use these wrapper functions we've built, then it immediately makes it possible to extend our plug-in. The wptuts_logs_clauses filter in particular would allow developers to alter each part of the SQL – and thus perform complex queries. We’ll note that its the job of any plug-in using these filters to make sure what they return is properly sanitised.

Hooks are just as useful when performing the other three main ‘operations’: inserting, updating and deleting data. Actions allow plug-ins to know when these are being performed – so they some action. In our context this might mean sending an email to an admin when a particular user performs a particular action. Filters, in the context of these operations, are useful for altering data before its inserted.

Be careful when naming hooks. A good hook name does several things:

  • Communicates when the hook is called or what its doing (e.g. you can guess what pre_get_posts and user_has_cap might do.
  • Be unique. Its recommended you prefix hooks with your plug-in’s name. Unlike functions, there won’t be an error if there is a clash between hook names – instead it will probably just ‘silently’ break one or more plug-ins.
  • Exhibits some sort of structure. Make your hooks predicable, and avoid naming hooks ‘on the fly’, as this can sometimes lead to seemingly random hook names. Instead plan as far ahead as possible the hooks you will use, and come up with an appropriate naming convention – and stick to it.
Tip: Generally its a good idea to mimic the same conventions as WordPress – as developers will more quickly understand what that hook is doing. Regarding using the plug-in’s name as a prefix: if your plug-in name is generic then this may not be sufficient to ensure uniqueness. Finally, do not give an action and a filter the same name.

Deleting Data

Deleting data is often the simplest of the wrappers – though it maybe required to perform some ‘clean up’ operations as well as simply removing the data. wp_delete_post() for instance, not only deletes the post from the *_posts table but also deletes the appropriate post meta, taxonomy relationships, comments and revisions etc.

In keeping with the comments of the previous section, we'll include two two actions: one triggered before and the other after a log has been deleted from the table. Following the WordPress’ naming convention for such actions:

  • _delete_ is triggered before deletion
  • _deleted_ is triggered after deletion
1
2
/**

3
 * Deletes an activity log from the database

4
 *

5
 *@param $log_id int ID of the activity log to be deleted

6
 *@return bool Whether the log was successfully deleted.

7
*/
8
function wptuts_delete_log( $log_id ){
9
    global $wpdb;        
10
11
    //Log ID must be positive integer

12
    $log_id = absint($log_id);     
13
    if( empty($log_id) )
14
         return false;
15
16
    do_action('wptuts_delete_log',$log_id);
17
    $sql = $wpdb->prepare("DELETE from {$wpdb->wptuts_activity_log} WHERE log_id = %d", $log_id);
18
19
    if( !$wpdb->query( $sql ) )
20
         return false;
21
22
    do_action('wptuts_deleted_log',$log_id);
23
24
    return true;
25
}

Documentation

I've been a bit lazy with the in-source documentation of the above API. In this series Tom McFarlin explains why you shouldn't be. You may have spent a lot of time developing your API functions, but if other developers don't know how to use them - they won't. You'll also be helping yourself, when after 6 months you've forgotten how the data should be given, or what you should expect to be returned.


Summary

Wrappers for your database table can range from the relatively simple (e.g. get_terms()) to extremely complex (e.g. the WP_Query class). Collectively they should seek to serve as the gateway to your table: allowing you to focus on the context in which they are used, and essentially forget what they are actually doing. The API you create is just a small example of the notion of 'separation of concerns', often attributed to Edsger W. Dijkstra in his paper On the role of scientific thought:

It is what I sometimes have called "the separation of concerns", which, even if not perfectly possible, is yet the only available technique for effective ordering of one's thoughts, that I know of. This is what I mean by "focusing one's attention upon some aspect": it does not mean ignoring the other aspects, it is just doing justice to the fact that from this aspect's point of view, the other is irrelevant. It is being one- and multiple-track minded simultaneously.

You can find the code used in this series, in its entirety, on GitHub. In the next part of this series we'll be looking at how you can maintain your database, and handle upgrades.