logfile = fopen($logfile, 'w') or die("Can't open logfile '$logfile'."); } else { // Assume it's an open file resource $this->logfile = $logfile; } } // By default, print out all statements executed if and only if SQL_DEBUG is defined as a true value. $this->echo = $echo; } function logline($msg) { if ($this->echo) { echo (is_string($this->echo)? $this->echo: "\n
"); echo htmlentities($msg); //echo $msg; } if ($this->logfile) { $find = array("\r", "\t", "\n\n"); $repl = array('', ' ', "\n"); fwrite($this->logfile, trim(str_replace($find, $repl, $msg))."\n\n"); } } function warn($msg, $fatal = false) { $this->logline('-- WARNING: '.$msg); $this->warnings[] = $this->warning = $msg; if ($fatal) exit(1); } function sum_query($result_field, $fields, $tables, $conditions = null, $allow_deleted = false) { if (!$allow_deleted) $conditions = $this->check_deleted($this->primary_table($tables), $conditions); $query = 'SELECT SUM(' . $this->escape($fields) . ') AS ' . $this->escape($result_field) . $this->from($tables, $allow_deleted) . $this->conditions($conditions); return $query; } function count_query($result_field, $fields, $tables, $conditions = null, $allow_deleted = false) { if (!$allow_deleted) $conditions = $this->check_deleted($this->primary_table($tables), $conditions); $query = 'SELECT COUNT(' . $this->escape($fields) . ') AS ' . $this->escape($result_field) . $this->from($tables, $allow_deleted) . $this->conditions($conditions); return $query; } function select_query($fields, $tables, $conditions = null, $order = null, $group = null, $limit = null, $page = 1, $allow_deleted = false) { $order_clause = $this->order_clause($order); $group_clause = $group? ' GROUP BY ' . $this->escape($group): ''; if (!$allow_deleted) $conditions = $this->check_deleted($this->primary_table($tables), $conditions); $skip = (intval($page) > 1)? (intval($limit) * (intval($page) - 1)).', ': ''; $limit_clause = intval($limit)? ' LIMIT '.$skip.intval($limit): ''; $query = 'SELECT ' . $this->escape($fields) . $this->from($tables, $allow_deleted) . $this->conditions($conditions) . $group_clause . $order_clause . $limit_clause; return $query; } function insert_query($table, $fields) { if ($this->common_fields) { // Automatically insert common fields $fields['creator_id'] = $this->user_id; $fields['date_created'] = $this->datetime(); } $query = 'INSERT INTO ' . $table . $this->conditions($fields, ' SET ', ', '); return $query; } function update_query($table, $updates, $conditions, $allow_deleted = false) { if (!$allow_deleted) $conditions = $this->check_deleted($this->primary_table($table), $conditions); $query = 'UPDATE ' . $this->escape($table) . $this->conditions($updates, ' SET ', ', ') . $this->conditions($conditions); return $query; } function delete_query($table, $conditions) { // Actually removes the rows from the database. $query = 'DELETE FROM ' . $this->escape($table) . $this->conditions($conditions); return $query; } function create_table_query($table, $fields, $keys = array(), $engine = 'InnoDB', $charset = 'utf8') { // Constructs a query to create the table. // // $fields should be an array of 'column_name' => array(options), where options can be: // * 'reference': 'table.field'; specifies that this is a foreign key to the given table. // * 'type': Either 'primary', or a valid MySQL type. Required unless 'reference' is given. // * 'signed': For numeric types, specifies whether the field is signed (true) or unsigned (false). // * 'length': An optional length for string column types, and for years (2 or 4). // * 'precision': Number of total digits for fixed-point decimal column types. Defaults to 10. // * 'scale': Number of digits after the decimal point for fixed-point column types. Defaults to 2. // * 'values': A required array of values for 'set' and 'enum' column types. // * 'null': Whether to allow null values. Defaults to NOT NULL, unless 'default' is null. // * 'default': Default value for the column. If null, overrides the 'null' option. // * 'unique': Whether to disallow duplicate values in this column. Defaults to false. // // $keys is an array of arrays, where each array is a list of column names in the key. // Each array may also contain the key 'unique', with the same meaning as above. // // $engine and $charset should usually not be specified. $common_fields = array( 'id' => array('type' => 'primary'), 'sequence' => array('type' => 'int', 'signed' => true, 'default' => 1), 'creator_id' => array('reference' => 'users.id', 'default' => null), 'date_created' => array('type' => 'datetime', 'default' => null), 'date_deleted' => array('type' => 'datetime', 'default' => null), 'description_id' => array('reference' => 'descriptions.id', 'default' => null), ); $lines = array(); $indexes = array(); $references = array(); if (!is_array($keys)) $keys = array(); foreach (array_merge($common_fields, $fields) as $column => $field) { $signed = $null = $default = null; $items = array($this->escape($column)); if (array_key_exists('unique', $field) and !empty($field['unique'])) { $keys[] = array($column, 'unique' => true); } if (array_key_exists('default', $field)) { $default = 'DEFAULT '.$this->sanitize($field['default']); if (!is_null($field['default']) and !array_key_exists('null', $field)) $null = 'NOT NULL'; } else if (empty($field['null'])) { // Note: This makes each field not null unless otherwise specified. $null = 'NOT NULL'; } if (!empty($field['reference'])) { $type = 'INT'; $signed = 'UNSIGNED'; $references[$column] = $field['reference']; } if (!empty($field['type'])) { switch ($field['type']) { // Aliases to set several attributes at once case 'primary': $type = 'INT'; // Todo: Consider bigint for primary keys $null = 'NOT NULL'; $signed = 'UNSIGNED'; $default = 'AUTO_INCREMENT PRIMARY KEY'; break; case 'serial': $type = 'BIGINT'; $null = 'NOT NULL'; $signed = 'UNSIGNED'; $default = 'AUTO_INCREMENT UNIQUE'; break; // Numeric types case 'int': case 'float': case 'double': case 'bigint': case 'integer': case 'tinyint': case 'smallint': case 'mediumint': $type = strtoupper($field['type']); if (array_key_exists('signed', $field)) $signed = $field['signed']? 'SIGNED': 'UNSIGNED'; break; case 'bool': case 'boolean': if (array_key_exists('default', $field)) { $val = is_null($field['default'])? 'NULL': empty($field['default'])? 'FALSE': 'TRUE'; $default = 'DEFAULT '.$val; } // Fall through case 'bit': $type = strtoupper($field['type']); //$signed = 'UNSIGNED'; break; case 'dec': case 'fixed': case 'decimal': if (array_key_exists('signed', $field)) $signed = $field['signed']? 'SIGNED': 'UNSIGNED'; $precision = array_key_exists('precision', $field)? intval($field['precision']): 10; $scale = array_key_exists('scale', $field)? intval($field['scale']): 2; $type = "DECIMAL($precision,$scale)"; break; // Date and time types case 'year': $length = array_key_exists('length', $field)? intval($field['length']): 4; $type = "YEAR($length)"; break; case 'date': case 'time': case 'datetime': case 'timestamp': $type = strtoupper($field['type']); break; // String types case 'char': case 'blob': case 'text': case 'binary': case 'varchar': case 'tinyblob': case 'tinytext': case 'longblob': case 'longtext': case 'varbinary': case 'mediumblob': case 'mediumtext': $type = strtoupper($field['type']); if (array_key_exists('length', $field)) $type .= '('.intval($field['length']).')'; break; case 'set': case 'enum': $values = array(); if (array_key_exists('values', $field) and is_array($field['values'])) { foreach ($field['values'] as $val) $values[] = $this->sanitize($val); } else { $this->warn("Missing $field[type] values for column $column."); } $type = strtoupper($field['type']).'('.join(',', $values).')'; break; default: $this->warn("Unsupported type '$field[type]' for column $column."); continue 2; } } else if (empty($type)) { $this->warn("Missing type for column $column."); continue; } $items[] = $type; if (!empty($signed)) $items[] = $signed; if (!empty($null)) $items[] = $null; if (!empty($default)) $items[] = $default; $lines[] = join(' ', $items); } foreach ($keys as $columns) { if (!is_array($columns)) $columns = array($columns); $unique = ''; if (array_key_exists('unique', $columns)) { if (empty($columns['unique'])) { unset($columns['unique']); } else { $columns['unique'] = 'date_deleted'; $unique = 'UNIQUE '; } } $indexes[$columns[0]] = true; $lines[] = $unique.'KEY ('.join(',', array_map(array($this, 'escape'), $columns)).')'; } foreach ($references as $column => $ref) { $col = $this->escape($column); $info = explode('.', $ref); switch (count($info)) { case 1: $reftab = $this->escape($ref); $refcol = $this->escape('id'); break; case 2: $reftab = $this->escape($info[0]); $refcol = $this->escape($info[1]); break; default: $this->warn("Unparsable reference '$ref' in column $column."); continue 2; } $cn = $this->escape($table.'__'.$column); $key = array_key_exists($column, $indexes)? '': "KEY ($col), "; $lines[] = $key."CONSTRAINT $cn FOREIGN KEY ($col) REFERENCES $reftab ($refcol)"; } $query = "CREATE TABLE ".$this->escape($table)." (\n ".join(",\n ", $lines)."\n) ENGINE=$engine DEFAULT CHARSET=$charset"; return $query; } function is_list($array) { foreach (array_keys($array) as $key) if (!is_int($key)) return false; return true; } function conditions($fields, $clause = ' WHERE ', $join = ' AND ') { $inserting = (trim($clause) == 'SET'); $joining = (trim($clause) == 'ON'); if (empty($fields)) { if ($inserting) $this->warn('Trying to update nothing'); return ''; } $results = array(); $operator = $inserting? '=': 'IS'; foreach ($fields as $key => $value) { if (is_null($value)) { $results[] = $this->escape($key)." $operator NULL"; } else if (is_array($value)) { if (empty($value)) { $results[] = 'FALSE'; } else if (is_numeric($key)) { $results[] = $this->conditions($value, '', (trim($join) == 'AND')? ' OR ': ' AND '); } else if ($this->is_list($value)) { $vals = array(); $has_null = false; foreach ($value as $item) { if (is_null($item)) { $has_null = true; } else { $vals[] = $this->sanitize($item); } } $column = $this->escape($key); switch (count($vals)) { case 0: $main = 'FALSE'; break; case 1: $main = $column.' = '.$vals[0]; break; default: $main = $column.' IN ('.join(', ', $vals).')'; break; } $results[] = $has_null? "($column IS NULL) OR ($main)": $main; } else { $results[] = $this->conditions($value, '', ' '.trim(strval($key)).' '); } } else if (is_numeric($key)) { $results[] = $value; } else if ($joining and is_string($value) and preg_match("/^[a-z]\\w+\\.\\w+$/", $value)) { $results[] = $this->escape($key).' <=> '.$this->escape($value); } else { $results[] = $this->escape($key).' = '.$this->sanitize($value); } } if (!$inserting) $results = array_map(array($this, 'wrap'), $results); return $clause . implode($join, $results); } function wrap($item) { return "($item)"; } function from($tables, $allow_deleted = false) { $primary = ''; $joining = ''; if (is_array($tables)) { foreach ($tables as $table => $conditions) { if (is_array($conditions)) { if (!$allow_deleted) $conditions = $this->check_deleted($table, $conditions); if (array_key_exists('on', $conditions)) { $table = array(strval($conditions['on']) => $table); unset($conditions['on']); } $joining .= ' LEFT JOIN ' . $this->escape($table) . $this->conditions($conditions, ' ON '); } else { $primary = $this->escape(is_numeric($table)? strval($conditions): array(strval($conditions) => $table)); } } } else { $primary = $this->escape(strval($tables)); } return ' FROM ' . $primary . $joining; } function primary_table($tables) { $table = null; if (is_string($tables)) { $table = $tables; } else if (is_array($tables)) { if (array_key_exists(0, $tables)) { $table = $tables[0]; } else { foreach ($tables as $key => $value) { if (is_string($value)) { $table = $key; break; } } } } if (empty($table)) $this->warn('Invalid table specification: '.print_r($tables, true), true); return $table; } function check_deleted($table, $conditions) { if (!$this->common_fields) { $result = $conditions; } else if (is_null($conditions)) { $result = array($table.'.date_deleted' => null); } else if (is_array($conditions)) { $result = $conditions; $result[$table.'.date_deleted'] = null; } else if (is_numeric($conditions)) { $result = array($table.'.date_deleted' => null, $table.'.id' => intval($conditions)); } else { $result = array($table.'.date_deleted' => null, $conditions); } return $result; } function operator($left, $op, $right, $allow_null = false) { if (count($right) or is_null($right)) { $result = $this->escape($left).' '.$op.' '.$this->sanitize($right); if ($allow_null) $result = $this->conditions(array($left => null, $result), '', ' OR '); } else if ($allow_null) { $result = $this->conditions(array($left => null), ''); } else { $result = 'FALSE'; } return $result; } function order_clause($order) { $clause = ' ORDER BY '; if (empty($order)) { $result = ''; } else if (is_array($order)) { $escaped = array(); foreach ($order as $key => $name) { $direction = strtoupper(trim($name)); if (in_array($direction, array('ASC', 'DESC'))) { $escaped[] = $this->escape($key).' '.$direction; } else { $escaped[] = $this->escape($name); } } $result = $clause . join(', ', $escaped); } else { $result = $clause . $this->escape($order); } return $result; } function escape($columns) { if (empty($columns)) { $result = '*'; } else if (is_array($columns)) { $escaped = array(); $result = ''; foreach ($columns as $key => $name) { if (is_numeric($key) and strtolower($name) == 'distinct') { $result = 'DISTINCT '; } else { $escaped[] = (is_numeric($key)? '': $this->escape($key).' AS ').$this->escape($name); } } $result .= join(', ', $escaped); } else { $result = '`'.str_replace('.', '`.`', $columns).'`'; } return $result; } function date($timestamp = null) { // Formats a date, defaulting to today, as expected by the MySQL server return is_null($timestamp)? date('Y-m-d'): date('Y-m-d', $timestamp); } function datetime($timestamp = null) { // Formats a time, defaulting to now, as expected by the MySQL server return is_null($timestamp)? date('Y-m-d H:i:s'): date('Y-m-d H:i:s', $timestamp); } function sanitize($value) { if (is_null($value)) { return 'NULL'; } else if (is_int($value)) { return intval($value); } else if (is_numeric($value)) { return "'$value'"; } else if ($value === false) { return 0; } else if ($value === true) { return 1; } else if (is_array($value)) { return '('.join(',', array_map(array($this, 'sanitize'), $value)).')'; } else if ($this->db) { return "'".mysql_real_escape_string(trim(strval($value)), $this->db)."'"; } else { return "'".mysql_escape_string(trim(strval($value)))."'"; } } } class Database extends Sql { var $prefix = ''; function __construct($details, $logfile = null) { // By default, print out all statements executed if and only if SQL_DEBUG is defined as a true value. $echo = (defined('SQL_DEBUG') and SQL_DEBUG and !empty($details['database']))? "\n
$details[database]: ": false; parent::__construct($logfile, $echo); foreach (array('username', 'password', 'host', 'database') as $key) { if (empty($details[$key])) $this->warn("Missing $key in connection details.", true); } $this->logline("-- Connecting to $details[database] on $details[username]:$details[password]@$details[host]"); $this->db = mysql_connect($details['host'], $details['username'], $details['password'], true) or die("Can't connect: " . mysql_error()); mysql_select_db($details['database'], $this->db) or die("Can't select database: " . mysql_error($this->db)); if (!empty($details['prefix'])) $this->prefix = $details['prefix']; } function query($query) { $this->logline($query.';'); $results = mysql_query($query, $this->db); if (!$results) $this->warn('Query failed: ' . mysql_error($this->db)); return $results; } function sum($fields, $tables, $conditions = null, $allow_deleted = false) { $result_field = 'num'; $query = $this->sum_query($result_field, $fields, $tables, $conditions, $allow_deleted); $results = $this->query($query); if (!$results) return null; $returned = mysql_num_rows($results); $request = null; if ($returned == 1) { $row = mysql_fetch_assoc($results); if (array_key_exists($result_field, $row)) { $request = intval($row[$result_field]); } else { $this->warn("Can't find '$result_field' in sum results."); } } else { $this->warn("$returned rows returned for a sum."); } return $request; } function count($fields, $tables, $conditions = null, $allow_deleted = false) { $result_field = 'num'; $query = $this->count_query($result_field, $fields, $tables, $conditions, $allow_deleted); $results = $this->query($query); if (!$results) return null; $returned = mysql_num_rows($results); $request = null; if ($returned == 1) { $row = mysql_fetch_assoc($results); if (array_key_exists($result_field, $row)) { $request = intval($row[$result_field]); $this->logline(' -- '.$request); } else { $this->warn("Can't find '$result_field' in count results."); } } else { $this->warn("$returned rows returned for a count."); } return $request; } function select($fields, $tables, $conditions = null, $allow_deleted = false, $group = null) { $query = $this->select_query($fields, $tables, $conditions, $order = null, $group, $limit = 1, $page = 1, $allow_deleted); $results = $this->query($query); if (!$results) return null; $returned = mysql_num_rows($results); if ($returned) { $row = mysql_fetch_assoc($results); $items = array(); foreach ($row as $key => $value) $items[] = $this->escape($key).' => '.$this->sanitize($value); $this->logline(' -- ( ' . join(', ', $items) . ' )'); if (!is_string($fields)) { $request = $row; } else if (array_key_exists($fields, $row)) { $request = $row[$fields]; } else if ($dot = strrpos($fields, '.')) { $fieldname = substr($fields, $dot + 1); if (array_key_exists($fieldname, $row)) { $request = $row[$fieldname]; } else { $this->warn("Can't find '$fields' or '$fieldname' in results."); } } else { $this->warn("Can't find '$fields' in results."); } } else { $this->logline(' -- Row not found.'); $request = null; } return $request; } function select_all($fields, $tables, $conditions = null, $order = null, $allow_deleted = false, $limit = null, $page = 1, $group = null) { $query = $this->select_query($fields, $tables, $conditions, $order, $group, $limit, $page, $allow_deleted); $results = $this->query($query); if (!$results) return null; $returned = mysql_num_rows($results); $s = ($returned == 1)? '': 's'; $this->logline(" -- Returned $returned row$s."); $request = array(); if (is_string($fields)) $fetch_field = $fields; $fieldname = is_string($fields)? ($dot = strrpos($fields, '.'))? substr($fields, $dot + 1): $fields: null; while ($row = mysql_fetch_assoc($results)) { $request[] = array_key_exists($fieldname, $row)? $row[$fieldname]: $row; } return $request; } function get($fields, $tables, $id) { return $this->select($fields, $tables, array($this->primary_table($tables).'.id' => intval($id))); } function insert($table, $fields) { //returns id of the new record, null if more than 1 row was inserted/modified. $result = null; $query = $this->insert_query($table, $fields); $results = $this->query($query); $affected = mysql_affected_rows($this->db); if ($affected == 1) { $result = mysql_insert_id($this->db); $this->logline(' -- Inserted as #'.$result); } else if ($affected > 1) { $this->warn("Inserted $affected rows."); } else { $this->warn('Insert failed.'); } return $result; } function update($table, $updates, $conditions) { //returns the number of affected rows if (!isset($conditions)) die('Empty $conditions in update!'); $this->query($this->update_query($table, $updates, $conditions)); $affected = mysql_affected_rows($this->db); $s = ($affected == 1)? '': 's'; $this->logline(" -- Updated $affected row$s."); return $affected; } function delete($table, $conditions) { if ($this->common_fields) { // Doesn't actually remove the rows from the database; sets the date_deleted field instead. $deleted = $this->update($table, array('date_deleted' => $this->datetime()), $conditions); } else { $deleted = $this->purge($table, $conditions); } //returns number of affected rows return $deleted; } function purge($table, $conditions) { // Actually removes the rows from the database; use delete() unless you're certain this is what you want. // returns: the number of affected (removed) rows $this->query($this->delete_query($table, $conditions)); $affected = mysql_affected_rows($this->db); $s = ($affected == 1)? '': 's'; $this->logline(" -- Deleted $affected row$s."); return $affected; } function save($table, $updates, $conditions) { // Update the record in the database, adding a new one if necessary // returns: the id of the new or old row if (!isset($conditions)) die('Empty $conditions in save!'); $old_vals = $this->select(is_array($updates)? array_keys($updates): $updates, $table, $conditions); if (is_null($old_vals)) { $this->logline(" -- Inserting new row."); if (is_array($conditions)) $updates = is_array($updates)? array_merge($updates, $conditions): $conditions; return $this->insert($table, $updates); } else if (empty($updates) or ($old_vals == $updates)) { $this->logline(" -- Nothing needs to be updated."); $result = true; } else { $this->logline(" -- Updating old rows."); $result = $this->update($table, $updates, $conditions); } if ($result and $this->common_fields) { if (is_array($conditions) and array_key_exists('id', $conditions)) { $result = $conditions['id']; } else if (array_key_exists('id', $updates)) { $result = $updates['id']; } else if (array_key_exists('id', $old_vals)) { $result = $old_vals['id']; } else { $result = $this->select('id', $table, $conditions); } } return $result; } function get_max($table, $field, $conditions = null, $allow_deleted = false) { if (!$allow_deleted) $conditions = $this->check_deleted($this->primary_table($table), $conditions); $result = $this->query('SELECT MAX('.$field.') AS `maximum_value` FROM '.$table.' '.$this->conditions($conditions)); $value = 0; if (!empty($result)) { $row = mysql_fetch_assoc($result); if (array_key_exists('maximum_value', $row)) { $value = $row['maximum_value']; } } return $value; } function get_property($table, $list_name, $item_id) { $result = $this->select('property_options.value', array( $table, 'property_options' => array('property_options.id' => $table.'.property_id'), 'property_lists' => array('property_lists.id' => 'property_options.list_id'), ), array( $table.'.item_id' => $item_id, 'property_lists.list_name' => $list_name, )); if (is_null($result)) { $result = $this->select('property_options.value', array( 'property_options', 'property_lists' => array('property_lists.id' => 'property_options.list_id'), ), array( 'property_options.is_default' => true, 'property_lists.list_name' => $list_name, )); } return $result; } function set_property($table, $list_name, $value, $item_id) { // Yes, this is messy. Can it be cleaned up? $list = $this->select(array('id', 'allow_multiple', 'allow_custom'), 'property_lists', array('list_name' => $list_name)); if (empty($list)) { $this->warn('Missing property list: '.$list_name); return false; } $options = $this->select_all(array($table.'.id', 'property_options.value'), array( $table, 'property_options' => array('property_options.id' => $table.'.property_id'), 'property_lists' => array('property_lists.id' => 'property_options.list_id'), ), array( $table.'.item_id' => $item_id, 'property_lists.list_name' => $list_name, )); $option_ids = array(); foreach ($options as $option) { if ($option['value'] == strval($value)) { // Option already set. $this->logline("-- Option '$value' already set for property list '$list_name'."); return true; } else { $option_ids[] = $option['id']; } } $option_id = $this->select('property_options.id', array( 'property_options', 'property_lists' => array('property_lists.id' => 'property_options.list_id'), ), array('property_lists.list_name' => $list_name, 'property_options.value' => $value)); if (empty($option_id)) { if ($list['allow_custom']) { $option_id = $this->insert('property_options', array('list_id' => $list['id'], 'value' => strval($value)), true); if (empty($option_id)) return false; } else { $this->warn("Missing option '$value' in property list '$list_name'."); return false; } } if ($list['allow_multiple']) { // Add a new row to the group $result = $this->insert($table, array('item_id' => $item_id, 'property_id' => $option_id)); } else if (count($option_ids) == 1) { // Update the old row directly $result = $this->update($table, array('property_id' => $option_id), array('item_id' => $item_id)); } else { // Delete any old rows, then add a new one if ($option_ids) $this->delete($table, array('property_id' => $option_ids)); $result = $this->insert($table, array('item_id' => $item_id, 'property_id' => $option_id)); } return $result; } function set_description($table, $row, $description) { $desc_id = $this->get('description_id', $table, $row); if ($desc_id and empty($description)) { $result = $this->update('descriptions', array('value' => ''), array('id' => $desc_id)); } else if (empty($description)) { $result = true; } else if (is_null($desc_id)) { $desc_id = $this->insert('descriptions', array('value' => $description), true); if (empty($desc_id)) return false; $result = $this->update($table, array('description_id' => $desc_id), array('id' => $row)); } else { $result = $this->save('descriptions', array('value' => $description), array('id' => $desc_id)); } return $result; } function has_column($table, $column) { $result = false; $description = $this->query('DESCRIBE ' . $this->escape($table)); if (!$results) return null; while ($table_row = mysql_fetch_assoc($description)) { if ($table_row['Field'] == $column) { $result = true; break; } } return $result; } } $db = new Database($db_info); //$db->echo = true; check_db_version(); //rollback_db(29); ?>