File: MyCSV.class.php

Recommend this page to a friend!
  Classes of Thiemo Kreuz  >  TM::MyCSV  >  MyCSV.class.php  >  Download  
File: MyCSV.class.php
Role: Class source
Content type: text/plain
Description: Main class
Class: TM::MyCSV
Manage CSV files like database tables
Author: By
Last change: - sort() accepts SORT_TIME and SORT_LOCALE_STRING to order values as dates ot based to the current locale.
- SORT_NULL and SORT_STRING fixed.
Date: 11 years ago
Size: 39,799 bytes
 

Contents

Class file image Download
<?php

/*
 * LICENSE
 * 1. If you like to use this class for personal purposes, it's free.
 * 2. For comercial purposes, please contact me (http://maettig.com/email).
 *    I'll send a license to you.
 * 3. When you copy the framework you must copy this notice with the source
 *    code. You may alter the source code, but you have to put the original
 *    with your altered version.
 * 4. The license is for all files included in this bundle.
 *
 * KNOWN BUGS/LIMITATIONS/TODO
 * - seek(-1, SEEK_CUR) does not work!
 * - sort("... id") doesn't work properly in all cases!
 * - fetch_row/fetch_array() aren't supported, use fetch_assoc/each() instead.
 * - num_fields() etc. aren't supported, use count($table->fields) instead.
 * - create/create_table() is not supported, use add_field() instead.
 * - What about some kind of GROUP BY?
 * - Add where($filter = "field='n' OR strtolower(substr(r,0,1))='x'") becomes
 *   eval("$data['field']=='n' OR strtolower(substr($data['r'],0,1))=='x'");
 */

/**
 * For compatibility with older PHP versions before 4.4.0.
 */
if (!defined('SORT_LOCALE_STRING'))
    define('SORT_LOCALE_STRING', 5);

/**
 * More special sorting type flags for use in MyCSV::sort().
 */
if (!defined('SORT_NAT'))
    define('SORT_NAT', 16);
if (!defined('SORT_TIME'))
    define('SORT_TIME', 17);
if (!defined('SORT_NULL'))
    define('SORT_NULL', 32);

/**
 * A text file based database complement.
 *
 * This class handles standard CSV or TXT text files as they where database
 * tables. It supports most benefits of both SQL tables and PHP arrays. It
 * doesn't need a real database management system nor does it require any
 * knowlege of the SQL language. It hides all filesystem functions so you don't
 * have to deal with file pointers, field delimiters, escape sequences and so
 * on. Because it uses the widespreaded standard CSV file format you are able
 * to create, read and update the tables using any spreadsheet software (e.g.
 * Excel). It supports user defined table sort similar to ORDER BY, auto
 * incremented ID numbers, limitation and joins similar to LIMIT and LEFT OUTER
 * JOIN, it's binary safe (uses work arounds for all known fgetcsv() related
 * bugs) and lots more.
 *
 * File format restrictions by design ("it's not a bug, it's a feature"):
 * - The first line of the CSV file <b>must</b> contain the column names.
 * - The CSV file <b>should</b> contain a column named "id". If this column is
 *   missing, it is added automatically. See {@link fields()}.
 * - Some critical characters (NUL, double quotes, backslashes) are replaced or
 *   backslashed to make the resulting CSV file compatible to all PHP versions
 *   (all known versions do have one or more bugs in <code>fgetcsv()</code>).
 *   See {@link write()}.
 *
 * See {@link MyCSV()}, {@link dump()}, {@link limit()} or {@link join()} for
 * some examples.
 *
 * Don't hesitate to report bugs or feature requests.
 *
 * @author Thiemo Mättig (http://maettig.com/)
 * @version 2009-09-02
 * @package TM
 * @requires PHP 4.0.5 (array_search, strcoll)
 */
class MyCSV
{
    /**
     * Array containing all the table field names. First have to be "id".
     *
     * @var array
     * @see add_field(), insert()
     */
    var $fields = array("id");

    /**
     * Two dimensional associative array containing all the table row data.
     *
     * @var array
     * @see data(), each()
     */
    var $data = array();

    /**
     * The field delimiter for separating values in the CSV file. Default is ","
     * (default CSV style). If not, the class tries to use ";" (European/German
     * CSV style), "\t" (tabulator separated values), "\0", "|", "&" (URI
     * encoded/parameter style), ":" (Unix /etc/passwd style) and " " (log file
     * style). Normaly you don't have to touch this variable. Simply choose your
     * delimiter when creating your initial CSV file.
     *
     * @var string Field delimiter.
     */
    var $delimiter = ",";

    /**
     * @var int Last insert ID.
     * @access private
     * @see insert_id()
     */
    var $insert_id = null;

    /**
     * File name of the CSV table with or without the .csv file name extension.
     * Don't change this cause write() will not realize you want another file.
     *
     * @var string File name of the .csv file.
     * @access private
     * @see MyCSV(), read(), tablename(), write()
     */
    var $filename = "";

    /**
     * @var bool Resource handle to the CSV file already opened.
     * @access private
     */
    var $_fp = false;

    /**
     * @var int Number of rows to be fetched, set by limit().
     * @access private
     * @see limit()
     */
    var $_limitRows = null;

    /**
     * Reads a CSV file and returns it as a MyCSV object.
     *
     * Reads a table into a new MyCSV object. The file name may be entered with
     * or without the <code>.csv</code> file extension. If the file does not
     * exist it will be created when calling {@link write()}. Set <i>length</i>
     * to the maximum number of bytes per row you expect (as you did in
     * fgetcsv()). Default is 10000 bytes per line. Setting this to 1000 may
     * speed up the method if you'r sure there is no longer line.
     *
     * For example, create a file called <code>table.csv</code> with the
     * following content and call the script below.
     *
     * <pre>id,value
     * 3,Example
     * 4,Another value
     * 7,Blue</pre>
     *
     * <pre><?php
     * require_once("MyCSV.class.php");
     * $table = new MyCSV("table");
     * while ($row = $table->each()) {
     *     echo $row['id'] . " is " . $row['value'] . "<br>";
     * }
     * ?></pre>
     *
     * @param tablename string
     * @param length int
     * @return MyCSV
     */
    function MyCSV($tablename = "", $length = 10000)
    {
        // Warning: Constructors can not return anything.
        if ($tablename) $this->read($tablename, $length);
    }

    /**
     * @param tablename string
     * @param length int
     * @return bool
     * @access private
     */
    function read($tablename, $length = 10000)
    {
        $this->filename = $tablename;
        // Add default file extension if missing.
        if (!preg_match('/\.\w+$/', $this->filename)) $this->filename .= ".csv";

        // Break if the CSV file for this table does not exist.
        if (!strstr($this->filename, "://") && !file_exists($this->filename))
            return false;

        if (!empty($GLOBALS['_MyCSV_locked'][$this->filename]))
        {
            user_error(
                "MyCSV::read() failed, file $this->filename is open already",
                E_USER_WARNING);
            $this->filename = "";
            return false;
        }
        $GLOBALS['_MyCSV_locked'][$this->filename] = true;

        // Open the CSV file for exclusive reading and writing OR reading only.
        if (is_writable($this->filename))
        {
            $this->_fp = @fopen($this->filename, "r+b");
        }
        // is_writable() may fail if Windows locked the file.
        if (!$this->_fp) $this->_fp = fopen($this->filename, "rb");
        if (!$this->_fp) return false;
        if (!strstr($this->filename, "://")) flock($this->_fp, LOCK_EX);

        $this->fields = fgetcsv($this->_fp, $length, $this->delimiter);
        // Try some delimiters, but use the default if nothing was found.
        $delimiters = str_replace($this->delimiter, "", ",;\t\0|&: ") . $this->delimiter;
        while (count($this->fields) < 2)
        {
            $this->delimiter = $delimiters[0];
            if (!$delimiters = substr($delimiters, 1)) break;
            rewind($this->_fp);
            $this->fields = fgetcsv($this->_fp, $length, $this->delimiter);
        }
        // On what position is the ID field? Returns $i = -1 if not found.
        for ($i = count($this->fields) - 1; $i > -1; $i--)
        {
            if (strcasecmp($this->fields[$i], "id") == 0) break;
        }
        $lastId = 0;
        $fieldsCount = count($this->fields);
        while ($row = fgetcsv($this->_fp, $length, $this->delimiter))
        {
            // Add missing id numbers.
            $id = isset($row[$i]) ? $row[$i] : $lastId + 1;
            $lastId = max($id, $lastId);
            $count = min($fieldsCount, count($row));
            for ($c = 0; $c < $count; ++$c)
            {
                // Strip "smart" backslashes. This makes the CSV files
                // binary-safe and compatible to PHP >=4.3.2 (which is when Ilia
                // Alshanetsky started ruining fgetcsv).
                $row[$c] = strtr($row[$c], array("\\\x7F" => "\x00",
                                                 "\\\x93" => '"',
                                                 '\\\\'   => '\\'));

                $this->data[$id][$this->fields[$c]] = $row[$c];
            }
        }
        // Always move the id column to the front.
        unset($this->fields[$i]);
        array_unshift($this->fields, "id");

        return true;
    }

    /**
     * Adds a new field (column) to the table. Returns false on failure, e.g.
     * if the field already exists.
     *
     * @param field string
     * @param afterField string
     * @return bool
     * @see insert(), drop_field()
     */
    function add_field($field, $afterField = null)
    {
        // Break if the field name contains invalid characters or already exists.
        if (!preg_match('/^[\w\x7F-\xFF]+$/is', $field) || in_array($field, $this->fields))
        {
            return false;
        }
        if (isset($afterField) && in_array($afterField, $this->fields))
        {
            $newFields = array();
            foreach ($this->fields as $oldField)
            {
                $newFields[] = $oldField;
                if (strcasecmp($oldField, $afterField) == 0) $newFields[] = $field;
            }
            $this->fields = $newFields;
        }
        else $this->fields[] = $field;
        return true;
    }

    /**
     * Moves the internal row pointer to the specified row number. This is an
     * alias for <code>{@link seek}(<i>row_number</i>, SEEK_SET)</code>.
     *
     * @param row_number int
     * @return bool
     */
    function data_seek($row_number)
    {
        return $this->seek($row_number, SEEK_SET);
    }

    /**
     * Deletes a table row specified by the <i>id</i>. Deletes all rows if no
     * <i>id</i> is given.
     *
     * @param id mixed
     * @return void
     */
    function delete($id = null)
    {
        // If delete(array('id' => 3)) is called, delete row 3.
        if (is_array($id) && isset($id['id'])) $id = $id['id'];
        if (isset($id))
        {
            // Delete one row if a valid id is given.
            if (!is_array($id)) unset($this->data[$id]);
        }
        else
        {
            // Delete all rows if no id was given (or id is null).
            $this->data = array();
            // Do not reset the ID numbers cause they where used already.
            ++$this->insert_id;
        }
    }

    /**
     * Deletes a field/column from the table.
     *
     * Returns false on failure, e.g. if <i>field</i> does not exists. Rewinds
     * the internal array pointer to the first element on success.
     *
     * @param field string
     * @return bool
     */
    function drop_field($field)
    {
        if (is_array($field) || strcasecmp($field, "id") == 0) return false;
        $offset = array_search($field, $this->fields);
        if ($offset === false || $offset === null) return false;
        array_splice($this->fields, $offset, 1);
        while (list($id) = each($this->data)) unset($this->data[$id][$field]);
        reset($this->data);
        return true;
    }

    /**
     * Clears the table. Remove all columns and all fields too.
     *
     * @return void
     */
    function drop_table()
    {
        $this->fields = array("id");
        $this->data = array();
        $this->insert_id = null;
    }

    /**
     * Gets the current data row and increase the internal pointer. This is an
     * alias for {@link each()}.
     *
     * @return array
     */
    function fetch_assoc()
    {
        return $this->each();
    }

    /**
     * Inserts a new table row using the next free auto incremented ID number.
     *
     * @param data array
     * @return void
     */
    function insert($data)
    {
        if (!is_array($data)) return false;

        // If data contains an unused id number, use it.
        if (isset($data['id']) && strlen($data['id']))
        {
            $this->insert_id = $data['id'];
        }
        // First auto increment id is always 1, but only for the initial row.
        elseif (!isset($this->insert_id) && empty($this->data))
        {
            $this->insert_id = 1;
        }
        // Don't use ++ because "x"++ returns "y" and that's not what we want.
        if (isset($this->data[$this->insert_id])) $this->insert_id += 1;
        if (!isset($this->insert_id) || isset($this->data[$this->insert_id]))
        {
            $this->insert_id = max(array_keys($this->data)) + 1;
        }

        $this->data[$this->insert_id] = $data;

        // Fetch missing field/column names from the first data row if needed.
        // This can be used instead of add_field().
        if (empty($this->fields) || count($this->fields) < 2)
        {
            unset($data['id']);
            $this->fields = array_merge(array("id"), array_keys($data));
        }
    }

    /**
     * Gets the ID generated from the previous insert() call.
     *
     * @return int
     */
    function insert_id()
    {
        return isset($this->insert_id) ? $this->insert_id : false;
    }

    /**
     * Performs a left outer join with another table.
     *
     * The tables are merged using a foreign key of the left table and the
     * primary key of the right table. This adds temporary columns to the left
     * table (temporary means, they aren't stored using {@link write()}). A
     * slightly complex example:
     *
     * <pre>echo "&lt;pre>";
     * $rightTable = new MyCSV();
     * $rightTable->insert(array('id' => 7, 'color' => "red"));
     * $rightTable->insert(array('id' => 8, 'color' => "yellow"));
     * $rightTable->dump();
     * echo "\n";
     * $leftTable = new MyCSV();
     * $leftTable->insert(array('thing' => "Table", 'color_id' => 7));
     * $leftTable->insert(array('thing' => "Chair", 'color_id' => 8));
     * $leftTable->insert(array('thing' => "Lamp", 'color_id' => 7));
     * $leftTable->dump();
     * echo "\n";
     * $leftTable->join($rightTable, "color_id");
     * while ($row = $leftTable->each()) {
     *     echo $row['thing'] . " is " . $row['color'] . "\n";
     * }</pre>
     *
     * @param rightTable array
     * @param foreignKey string
     * @return void
     */
    function join(&$rightTable, $foreignKey)
    {
        if (is_array($rightTable)) $rightData = $rightTable;
        else
        {
            $rightData = $rightTable->data;
            // If filename is empty, prefix is empty too and not used below.
            $prefix = preg_replace('/\.\w+$/', '', basename($rightTable->filename));
        }

        reset($this->data);
        while (list($id) = each($this->data))
        {
            if (strcasecmp($foreignKey, "id") == 0) $fid = $id;
            else $fid = $this->data[$id][$foreignKey];
            if (isset($rightData[$fid]))
            {
                // Right table is modified here and used as some kind of cache.
                if (!empty($prefix) && !isset($rightData[$fid][$prefix . ".id"]))
                {
                    foreach ($rightData[$fid] as $field => $value)
                    {
                        $rightData[$fid][$prefix . "." . $field] = &$rightData[$fid][$field];
                    }
                }
                // Duplicate keys are used from the left (original) table.
                $this->data[$id] += $rightData[$fid];
            }
        }

        // Reset the internal pointer.
        reset($this->data);
    }

    /**
     * Limits the number of rows to be fetched.
     *
     * Use <code>limit(2)</code> to fetch the first two rows only when calling
     * {@link each()} (or {@link fetch_assoc()}). Use <code>limit(2, $id)</code>
     * to fetch the next two rows, where <code>$id</code> is calculated using
     * <code>{@link first()}</code> for the first page and using <code>{@link
     * next}($id, 2)</code>, <code>next($id, 4)</code> and so on for all other
     * pages. Example:
     *
     * <pre>$table = new MyCSV("table");
     * for ($i = 10; $i < 21; $i++) {
     *   $table->insert(array('text' => "Text $i"));
     * }
     * // Order the table first because limit() depends on this.
     * $table->sort("text DESC");
     * // Limit to 5 rows starting from a specific id.
     * $rows = 5;
     * $id = isset($_REQUEST['id']) ? $_REQUEST['id'] : $table->first();
     * $table->limit($rows, $id);
     * while ($row = $table->each()) {
     *   echo "ID $row[id]: $row[text]<br>";
     * }
     * // Calculate and display the link targets for paging.
     * $first = $table->first();
     * $prev  = $table->prev($id, $rows);
     * $next  = $table->next($id, $rows);
     * $last  = $table->prev($table->last(), ($table->count() - 1) % $rows);
     * if (strcmp($first, $id)) echo "&lt;a href=\"$PHP_SELF?id=$first\">First&lt;/a> ";
     * if ($prev)               echo "&lt;a href=\"$PHP_SELF?id=$prev\">Prev&lt;/a> ";
     * if ($next)               echo "&lt;a href=\"$PHP_SELF?id=$next\">Next&lt;/a> ";
     * if (strcmp($last, $id))  echo "&lt;a href=\"$PHP_SELF?id=$last\">Last&lt;/a>";</pre>
     *
     * Call <code>limit()</code> (or <code>limit(0)</code> or something like
     * that) to reset the limitation.
     *
     * <i>Warning! The limitation has no effect on {@link delete()},
     * {@link update()} and so on! All following method calls like {@link sort()}
     * or {@link join()} that {@link seek sets} or {@link reset resets} the
     * internal pointer will change the starting ID (but not the number of rows)
     * set by limit().</i>
     *
     * @param rows int
     * @param id mixed
     * @param whence int
     * @return bool
     * @see seek()
     */
    function limit($rows = null, $id = null, $whence = null)
    {
        // Number of rows < 1 resets the limitation.
        $this->_limitRows = $rows > 0 ? $rows : null;
        return isset($id) ? $this->seek($id, $whence) : $this->reset();
    }

    /**
     * Gets the number of rows in the table.
     *
     * @return int
     * @see count()
     */
    function num_rows()
    {
        return count($this->data);
    }

    /**
     * Gets the table name without the default .csv file extension.
     *
     * The path returned can be used in {@link MyCSV()} without any change.
     * Directories are not removed from the string, if present.
     *
     * @return string
     */
    function tablename()
    {
        return preg_replace('{^\./|\.csv$}', '', $this->filename);
    }

    /**
     * Updates a table row with some new field/value pairs.
     *
     * Examples:
     *
     * <pre>$table->update(array(...), 3);
     * $table->update(array('id' => 3, ...));
     * $table->update(array('id' => 7, ...), 3); // Moves ID 3 to ID 7</pre>
     *
     * @param data array
     * @param id mixed
     * @return bool
     */
    function update($data, $id = null)
    {
        if (!is_array($data)) return false;
        // update(array(...)) without an ID doesn't make sense.
        if (!isset($data['id']) && !isset($id)) return false;

        // update(array(...), 3) becomes update(array('id' => 3, ...), 3)
        if (!isset($data['id'])) $data['id'] = $id;
        // update(array('id' => 7, ...)) becomes update(array('id' => 7, ...), 7)
        elseif (!isset($id)) $id = $data['id'];
        // update(array('id' => 7, ...), 3) if forbidden if ID 7 already exists.
        elseif (strcmp($data['id'], $id) != 0 && isset($this->data[$data['id']]))
        {
            return false;
        }

        // Duplicate keys will be used from the new row. Due to the cast
        // update() does an insert() if required but will cause a warning.
        $this->data[$data['id']] = $data + (array)$this->data[$id];
        // update(array('id' => 7, ...), 3) moves ID 3 to 7, so ID 3 is killed.
        if (strcmp($data['id'], $id) != 0) unset($this->data[$id]);
        return true;
    }

    /**
     * Gets the number of rows in the table. This is an alias for
     * {@link num_rows()}.
     *
     * @return int
     */
    function count()
    {
        return count($this->data);
    }

    /**
     * Gets the current data row and increases the internal pointer. See
     * {@link MyCSV()} for an example.
     *
     * @return array
     */
    function each()
    {
        // Don't return more rows if the limit() is reached.
        if (isset($this->_limitRows) && --$this->_limitRows < 0) return false;
        if (!list($id, $data) = each($this->data)) return false;
        return array('id' => $id) + $data;
    }

    /**
     * Sets the internal pointer to the last data row. Returns the last data
     * row.
     *
     * @return array
     * @see reset(), last()
     */
    function end()
    {
        return end($this->data);
    }

    /**
     * Checks if the data row specified by the ID exists.
     *
     * @param id mixed
     * @return bool
     * @see row_exists()
     */
    function id_exists($id)
    {
        return isset($this->data[$id]);
    }

    /**
     * Gets an array containing all the IDs of the table.
     *
     * @return array
     * @see min(), max(), first(), last(), prev(), next(), rand()
     */
    function ids()
    {
        return array_keys($this->data);
    }

    /**
     * Sorts the table rows by ID. This is identical to
     * <code>{@link sort}("id")</code> but a bit faster.
     *
     * @param sort_flags int
     * @return void
     */
    function ksort($sort_flags = 0)
    {
        return ksort($this->data, $sort_flags);
    }

    /**
     * Sorts the table rows by ID in reverse order. This is identical to
     * <code>{@link sort}("id DESC")</code> but a bit faster.
     *
     * @param sort_flags int
     * @return void
     */
    function krsort($sort_flags = 0)
    {
        return krsort($this->data, $sort_flags);
    }

    /**
     * Gets the smallest ID number used in the table. Typically, this is 1.
     *
     * @return int
     */
    function min()
    {
        if (!$this->data) return false;
        return min(array_keys($this->data));
    }

    /**
     * Gets the biggest ID number used in the table. This is often the same as
     * {@link insert_id()} which returns the last inserted ID. But unlike that,
     * max() doesn't depend on a previous call of {@link insert()}.
     *
     * @return int
     */
    function max()
    {
        if (!$this->data) return false;
        return max(array_keys($this->data));
    }

    /**
     * Gets the first ID number from the table. This depends on how's the table
     * sorted and isn't identical to {@link min()} in all cases.
     *
     * @return int
     * @see last(), prev(), reset()
     */
    function first()
    {
        if (!$this->data) return false;
        return array_shift(array_keys($this->data));
    }

    /**
     * Gets the last ID number used in the table. This depends on how's the
     * table sorted and isn't identical to {@link max()} in all cases.
     *
     * @return int
     * @see first(), next(), end()
     */
    function last()
    {
        if (!$this->data) return false;
        return array_pop(array_keys($this->data));
    }

    /**
     * Gets the previous ID number. Use <i>offset</i> to get another ID near to
     * the row specified by <i>id</i>. Default is 1 (one backward). Returns
     * false if there is no row at this position.
     *
     * @param id mixed
     * @param offset int
     * @return int
     * @see next(), first()
     */
    function prev($id, $offset = 1)
    {
        return $this->next($id, -$offset);
    }

    /**
     * Gets the next ID number. Use <i>offset</i> to get another ID near to the
     * row specified by <i>id</i>. Default is 1 (one forward). Returns false if
     * there is no row at this position.
     *
     * @param id mixed
     * @param offset int
     * @return int
     * @see prev(), last()
     */
    function next($id, $offset = 1)
    {
        $ids = array_keys($this->data);
        //- Add sort(ids) to return the nearest smaller/bigger ID numbers.
        $i = array_search($id, $ids) + $offset;
        return isset($ids[$i]) ? $ids[$i] : false;
    }

    /**
     * Picks one or more random ID numbers out of the table.
     *
     * @param num_req int
     * @return int
     * @see ids()
     */
    function rand($num_req = 1)
    {
        return empty($this->data) ? false : array_rand($this->data, $num_req);
    }

    /**
     * Sets the internal pointer to the first data row. Returns the first data
     * row.
     *
     * @return array
     * @see end(), each(), first()
     */
    function reset()
    {
        return reset($this->data);
    }

    /**
     * Looks if a data row is already in the table.
     *
     * @param search array
     * @return bool
     * @see id_exists()
     */
    function row_exists($search)
    {
        reset($this->data);
        // foreach() destroyed the array in PHP 5.2.5.
        while (list($id, $row) = each($this->data))
        {
            reset($search);
            while (list($key, $value) = each($search))
            {
                if (!isset($row[$key]) || $row[$key] != $value) continue 2;
            }
            return true;
        }
        reset($this->data);
        return false;
    }

    /**
     * Orders the table rows by one or more columns.
     *
     * Sorting order flags:
     * - ASC or SORT_ASC - Sort in ascending order (default).
     * - DESC or SORT_DESC - Sort in descending order.
     *
     * Sorting type flags:
     * - SORT_REGULAR - Compare items normally (default).
     * - SORT_NUMERIC - Compare items numerically.
     * - SORT_STRING - Compare items as strings.
     * - SORT_LOCALE_STRING - Compare items as strings, based on the current
     *   locale. Don't forget to use setlocale() before.
     * - SORT_NAT - Compare items using a "natural order" algorithm.
     * - SORT_TIME - Compare items as date and time values. This uses
     *   strtotime() to convert the strings from the CSV file (everything in a
     *   CSV file is a string) into timestamps and compares the timestamps.
     *
     * Special condition flag: SORT_NULL - Move empty elements to the end.
     *
     * No two sorting flags of the same type can be specified after each field.
     * Some examples:
     *
     * <pre>setlocale(LC_ALL, "de_DE@euro", "de_DE", "deu_deu");
     * $table->sort("a, b DESC");
     * $table->sort("a b DESC"); // Same as above
     * $table->sort("a", "b", SORT_DESC); // Same as above
     * $table->sort("a SORT_LOCALE_STRING SORT_NULL b SORT_NULL");
     * $table->sort("a SORT_NAT, b SORT_NAT, c");</pre>
     *
     * @param sort_flags mixed
     * @return void
     */
    function sort($sort_flags)
    {
        // sort() can be called using array_multisort()-like multiple
        // parameters or a SQL-like string argument.
        if (func_num_args() > 1) $sort_flags = func_get_args();
        else $sort_flags = preg_split('/[,\s]+/s', trim($sort_flags));
        // trim(..., ", \t\n\r;") would be better but works in PHP 4.1.0+ only.

        // Reset the _cmpFields array first.
        $this->_cmpFields = array();
        $p = -1;

        // Calculate the _cmpFields array for use in _cmp().
        foreach ($sort_flags as $f)
        {
            $f = preg_replace('/^(A|DE)SC$/i', 'SORT_\0', $f);
            // Always use the integer values of predefined constants if available.
            if (defined(strtoupper($f))) $f = constant(strtoupper($f));

            // Ignore ascending order but store everything else in the associative array.
            if ($f == SORT_ASC)      continue;
            elseif ($f == SORT_DESC) $this->_cmpFields[$p]['order'] = -1;
            elseif (is_int($f))      $this->_cmpFields[$p]['type'] |= $f;
            else
            {
                ++$p;
                $this->_cmpFields[] = array('field' => $f, 'order' => 1, 'type' => 0);
            }
        }

        if (strcasecmp($this->_cmpFields[0]['field'], "id") == 0)
        {
            if ($this->_cmpFields[0]['order'] > 0) ksort($this->data);
            else krsort($this->data);
        }
        else
        {
            // Call uasort() using the _cmp() function in the class.
            uasort($this->data, array(&$this, '_cmp'));
        }

        // Reset the internal pointer.
        reset($this->data);
    }

    /**
     * Callback for use in uasort() called in sort().
     *
     * @param a array
     * @param b array
     * @return bool
     * @access private
     */
    function _cmp(&$a, &$b)
    {
        foreach ($this->_cmpFields as $f)
        {
            // Using this sorting type, empty elements always move to the end.
            if ($f['type'] & SORT_NULL)
            {
                if (strlen($a[$f['field']]) <= 0 || strlen($b[$f['field']]) <= 0)
                    $f['order'] = -1;
            }

            switch ($f['type'] & ~SORT_NULL)
            {
                case SORT_NUMERIC:
                    // Take both arguments as numbers and return their difference.
                    $result = ($a[$f['field']] - $b[$f['field']]) * $f['order'];
                    break;
                case SORT_STRING:
                    // Take both arguments as strings and use strcasecmp() for comparing.
                    $result = strcasecmp($a[$f['field']], $b[$f['field']]) * $f['order'];
                    break;
                case SORT_LOCALE_STRING:
                    // Locale based string comparison.
                    $result = strcoll(strtolower($a[$f['field']]), strtolower($b[$f['field']])) * $f['order'];
                    break;
                case SORT_NAT:
                    $result = strnatcasecmp($a[$f['field']], $b[$f['field']]) * $f['order'];
                    break;
                case SORT_TIME:
                    $result = (strtotime($a[$f['field']]) - strtotime($b[$f['field']])) * $f['order'];
                    break;
                default:
                    // By default, thrust in PHP's automatic type conversion.
                    $result = ($a[$f['field']] == $b[$f['field']]) ? 0 :
                        ($a[$f['field']] > $b[$f['field']] ? $f['order'] : -$f['order']);
                    break;
            }
            // Continue (and maybe return 0) if both arguments are equal.
            if ($result != 0)
                return $result;
        }
    }

    /**
     * Gets a table row including their ID number. Returns false if the row does
     * not exist.
     *
     * @param id mixed
     * @return array
     */
    function data($id)
    {
        return isset($this->data[$id]) ? array('id' => $id) + $this->data[$id]
            : false;
    }

    /**
     * Dumps the table to screen.
     *
     * Example:
     *
     * <pre><?php
     * require_once("MyCSV.class.php");
     * $table = new MyCSV("people");
     * $table->insert(array('name' => "Adam", 'age'  => 23));
     * $table->insert(array('name' => "Bill", 'age'  => 19));
     * echo "&lt;pre>";
     * $table->dump();
     * ?></pre>
     *
     * @return void
     * @see export()
     */
    function dump()
    {
        echo $this->export();
    }

    /**
     * Checks if the CSV file for this table already exists.
     *
     * @return bool
     */
    function exists()
    {
        return file_exists($this->filename);
    }

    /**
     * Returns a complete CSV dump of the table.
     *
     * @return string
     * @see write(), dump()
     */
    function export()
    {
        $count_fields = count($this->fields);
        $tr_from = array('"',  "\x00");
        $tr_to   = array('""', "\\\x7F");

        $csv = implode($this->delimiter, $this->fields) . "\r\n";
        reset($this->data);
        while (list($id, $row) = each($this->data))
        {
            if (strpos($id, $this->delimiter) === false &&
                strpos($id, '"') === false)
            {
                $csv .= $id;
            }
            else
            {
                $csv .= '"' . str_replace('"', '""', $id) . '"';
            }
            for ($c = 1; $c < $count_fields; ++$c)
            {
                $csv .= $this->delimiter;
                $d = @$row[$this->fields[$c]];
                if (strlen($d))
                {
                    // Add "smart" backslashes. This makes the CSV files
                    // binary-safe and compatible to PHP >=4.3.2 (which is when
                    // Ilia Alshanetsky started ruining fgetcsv).
                    $d = preg_replace('/\\\(?=\\\|\x00|"|\x7F|\x93|$)/s',
                        '\\\\\0', $d);
                    // Workaround for some more bugs in PHP 4.3.2 to 4.3.10.
                    $d = preg_replace('/(^"|"$)/s', "\\\x93", $d);
                    $csv .= '"' . str_replace($tr_from, $tr_to, $d) . '"';
                }
            }
            $csv .= "\r\n";
        }
        reset($this->data);
        return $csv;
    }

    /**
     * Checks if the CSV file for this table is writeable.
     *
     * @return bool
     */
    function is_writeable()
    {
        return is_writeable($this->filename);
    }

    /**
     * Sets the internal pointer to the data row specified by an ID or offset.
     *
     * If <i>whence</i> is left out, seek jumps to a specific ID (default).
     *
     * <i>whence</i> may be SEEK_SET to set an absolute position counted from
     * the start of the table, SEEK_CUR for a relative position or SEEK_END for
     * an absolute position counted from the end of the table. The behaviour of
     * these options is identical to fseek(). Keep in mind that <i>id</i>
     * represents an offset instead of a row ID in these cases. Example:
     *
     * <pre>$table = new MyCSV("table");
     * $table->insert(array('id' => 3)); // 1st row
     * $table->insert(array('id' => 7)); // 2nd row
     * $table->seek(1, SEEK_SET); // Jump to 2nd row
     * $row = $table->fetch_assoc();
     * echo $row['id']; // Output: 7
     * $table->seek(7); // Jump to 2nd row</pre>
     *
     * @param id mixed
     * @param whence int
     * @return bool
     * @see limit()
     */
    function seek($id = 0, $whence = null)
    {
        if (!isset($whence)) $id = array_search($id, array_keys($this->data));
        // Calculate absolute offset if end-of-file plus offset is requested.
        if ($whence == SEEK_END) $id = count($this->data) - 1 - abs($id);
        // Reset array pointer in SEEK_SET and SEEK_END mode.
        if ($whence != SEEK_CUR) reset($this->data);
        for ($i = 0; $i < $id; ++$i)
        {
            // Return false if offset is out of array bounds.
            if (!next($this->data)) return false;
        }
        return true;
    }

    /**
     * Rewrites the CSV table file or creates a new one.
     *
     * write() closes the file when done.
     *
     * The files created are binary-safe and compatible with any external spread
     * sheet software (e.g. Excel) with a few exceptions:
     * - NUL bytes (#0) are replaced with a backslash followed by a DEL
     *   character (#127). That's because older PHP versions aren't able to
     *   process CSV files containing NUL bytes.
     * - Double quotes at the beginning and end of a value are replaced with a
     *   backslash followed by a left double quote (#147). That's because newer
     *   (!) PHP versions strip such quotes.
     * - Backslashes in front of a NUL, DEL, double quote, left double quote,
     *   other backslash or end of string are replaced with two backslashes.
     * That's what I call "smart backslashes". You don't need to know about this
     * if you'r not using external software to modify your CSV files. Due to the
     * replacements described above, the class <b>is</b> able to process any
     * binary data. {@link MyCSV()} knows about these rules and undo the
     * replacements immediatelly.
     *
     * Binary safety tested with the following PHP versions: 4.3.1, 4.3.3,
     * 4.3.5, 4.3.9, 4.3.10, 4.4.0, 5.0.4.
     *
     * @param tablename string
     * @param delimiter string
     * @return bool
     */
    function write($tablename = "", $delimiter = "")
    {
        // Add default file extension if missing.
        if ($tablename && !preg_match('/\.\w+$/', $tablename))
        {
            $tablename .= ".csv";
        }
        // Close the original CSV file and prepare to create a new one.
        if ($tablename && $tablename != $this->filename)
        {
            $this->close();
            $this->filename = $tablename;
        }
        if (!$this->filename) return false;

        // Open the CSV file for exclusive writing if not opened already.
        if (!$this->_fp)
        {
            // Mode "w" is the only one who's able to create the file properly.
            $this->_fp = fopen($this->filename, "wb");
            if (!$this->_fp) return false;
            flock($this->_fp, LOCK_EX);
        }

        // Switch to another field delimiter if present.
        if ($delimiter) $this->delimiter = $delimiter;

        rewind($this->_fp);
        if (!fwrite($this->_fp, $this->export()))
        {
            // Triggers an user error if the CSV file is write-protected/read-only.
            user_error("MyCSV::write() failed, file $this->filename seems to be read only",
                E_USER_WARNING);
            return false;
        }
        ftruncate($this->_fp, ftell($this->_fp));
        $this->close();

        // Drop empty tables.
        if (count($this->fields) <= 1 && empty($this->data))
        {
            unlink($this->filename);
        }

        return true;
    }

    /**
     * @access private
     */
    function close()
    {
        if ($this->_fp)
        {
            fflush($this->_fp);
            flock($this->_fp, LOCK_UN);
            fclose($this->_fp);
            $this->_fp = false;
            if (isset($GLOBALS['_MyCSV_locked'][$this->filename]))
                unset($GLOBALS['_MyCSV_locked'][$this->filename]);
        }
    }
}

For more information send a message to info at phpclasses dot org.