<?php
// $Id: field_sql_storage.module,v 1.44 2010/03/27 05:52:49 webchick Exp $

/**
 * @file
 * Default implementation of the field storage API.
 */

/**
 * Implements hook_help().
 */
function field_sql_storage_help($path, $arg) {
  switch ($path) {
    case 'admin/help#field_sql_storage':
      $output = '';
      $output .= '<h3>' . t('About') . '</h3>';
      $output .= '<p>' . t('The Field SQL storage module stores field data in the database. It is the default field storage module; other field storage mechanisms may be available as contributed modules. See the <a href="@field-help">Field module help page</a> for more information about fields.', array('@field-help' => url('admin/help/field'))) . '</p>';
      return $output;
  }
}

/**
 * Implements hook_field_storage_info().
 */
function field_sql_storage_field_storage_info() {
  return array(
    'field_sql_storage' => array(
      'label' => t('Default SQL storage'),
      'description' => t('Stores fields in the local SQL database, using per-field tables.'),
    ),
  );
}

/**
 * Generate a table name for a field data table.
 *
 * @param $field
 *   The field structure.
 * @return
 *   A string containing the generated name for the database table
 */
function _field_sql_storage_tablename($field) {
  return "field_data_{$field['field_name']}" . ($field['deleted'] ? "_{$field['id']}" : '');
}

/**
 * Generate a table name for a field revision archive table.
 *
 * @param $name
 *   The field structure.
 * @return
 *   A string containing the generated name for the database table
 */
function _field_sql_storage_revision_tablename($field) {
  return "field_revision_{$field['field_name']}" . ($field['deleted'] ? "_{$field['id']}" : '');
}

/**
 * Generate a column name for a field data table.
 *
 * @param $name
 *   The name of the field
 * @param $column
 *   The name of the column
 * @return
 *   A string containing a generated column name for a field data
 *   table that is unique among all other fields.
 */
function _field_sql_storage_columnname($name, $column) {
  return $name . '_' . $column;
}

/**
 * Generate an index name for a field data table.
 *
 * @param $name
 *   The name of the field
 * @param $column
 *   The name of the index
 * @return
 *   A string containing a generated index name for a field data
 *   table that is unique among all other fields.
 */
function _field_sql_storage_indexname($name, $index) {
  return $name . '_' . $index;
}

/**
 * Retrieve or assign an entity type id for an entity type.
 *
 * @param $entity_type
 *   The entity type, such as 'node' or 'user'.
 * @return
 *   The entity type id.
 *
 */
function _field_sql_storage_etid($entity_type) {
  $etid = variable_get('field_sql_storage_' . $entity_type . '_etid', NULL);
  if (is_null($etid)) {
    $etid = db_insert('field_config_entity_type')->fields(array('type' => $entity_type))->execute();
    variable_set('field_sql_storage_' . $entity_type . '_etid', $etid);
  }
  return $etid;
}

/**
 * Return the database schema for a field. This may contain one or
 * more tables. Each table will contain the columns relevant for the
 * specified field. Leave the $field's 'columns' and 'indexes' keys
 * empty to get only the base schema.
 *
 * @param $field
 *   The field structure for which to generate a database schema.
 * @return
 *   One or more tables representing the schema for the field.
 */
function _field_sql_storage_schema($field) {
  $deleted = $field['deleted'] ? 'deleted ' : '';
  $current = array(
    'description' => "Data storage for {$deleted}field {$field['id']} ({$field['field_name']})",
    'fields' => array(
      'etid' => array(
        'type' => 'int',
        'unsigned' => TRUE,
        'not null' => TRUE,
        'description' => 'The entity type id this data is attached to',
      ),
      'bundle' => array(
        'type' => 'varchar',
        'length' => 128,
        'not null' => TRUE,
        'default' => '',
        'description' => 'The field instance bundle to which this row belongs, used when deleting a field instance',
      ),
      'deleted' => array(
        'type' => 'int',
        'size' => 'tiny',
        'not null' => TRUE,
        'default' => 0,
        'description' => 'A boolean indicating whether this data item has been deleted'
      ),
      'entity_id' => array(
        'type' => 'int',
        'unsigned' => TRUE,
        'not null' => TRUE,
        'description' => 'The entity id this data is attached to',
      ),
      'revision_id' => array(
        'type' => 'int',
        'unsigned' => TRUE,
        'not null' => FALSE,
        'description' => 'The entity revision id this data is attached to, or NULL if the entity type is not versioned',
      ),
      // @todo Consider storing language as integer.
      'language' => array(
        'type' => 'varchar',
        'length' => 32,
        'not null' => TRUE,
        'default' => '',
        'description' => 'The language for this data item.',
      ),
      'delta' => array(
        'type' => 'int',
        'unsigned' => TRUE,
        'not null' => TRUE,
        'description' => 'The sequence number for this data item, used for multi-value fields',
      ),
    ),
    // @todo Is the primary key needed at all ?
    'primary key' => array('etid', 'entity_id', 'deleted', 'delta', 'language'),
    'indexes' => array(
      'etid' => array('etid'),
      'bundle' => array('bundle'),
      'deleted' => array('deleted'),
      'entity_id' => array('entity_id'),
      'revision_id' => array('revision_id'),
      'language' => array('language'),
    ),
  );

  // Add field columns.
  foreach ((array) $field['columns'] as $column_name => $attributes) {
    $real_name = _field_sql_storage_columnname($field['field_name'], $column_name);
    $current['fields'][$real_name] = $attributes;
  }

  // Add indexes.
  foreach ((array) $field['indexes'] as $index_name => $columns) {
    $real_name = _field_sql_storage_indexname($field['field_name'], $index_name);
    foreach ($columns as $column_name) {
      $current['indexes'][$real_name][] = _field_sql_storage_columnname($field['field_name'], $column_name);
    }
  }

  // Construct the revision table. The primary key includes
  // revision_id but not entity_id so that multiple revision loads can
  // use the IN operator.
  $revision = $current;
  $revision['description'] = "Revision archive storage for {$deleted}field {$field['id']} ({$field['field_name']})";
  $revision['revision_id']['description'] = 'The entity revision id this data is attached to';
  $revision['primary key'] = array('etid', 'revision_id', 'deleted', 'delta', 'language');

  return array(
    _field_sql_storage_tablename($field) => $current,
    _field_sql_storage_revision_tablename($field) => $revision,
  );
}

/**
 * Implements hook_field_storage_create_field().
 */
function field_sql_storage_field_storage_create_field($field) {
  $schema = _field_sql_storage_schema($field);
  foreach ($schema as $name => $table) {
    db_create_table($name, $table);
  }
  drupal_get_schema(NULL, TRUE);
}

/**
 * Implements hook_field_update_field_forbid().
 *
 * Forbid any field update that changes column definitions if there is
 * any data.
 */
function field_sql_storage_field_update_forbid($field, $prior_field, $has_data) {
  if ($has_data && $field['columns'] != $prior_field['columns']) {
    throw new FieldUpdateForbiddenException("field_sql_storage cannot change the schema for an existing field with data.");
  }
}

/**
 * Implements hook_field_storage_update_field().
 */
function field_sql_storage_field_storage_update_field($field, $prior_field, $has_data) {
  if (! $has_data) {
    // There is no data. Re-create the tables completely.
    $prior_schema = _field_sql_storage_schema($prior_field);
    foreach ($prior_schema as $name => $table) {
      db_drop_table($name, $table);
    }
    $schema = _field_sql_storage_schema($field);
    foreach ($schema as $name => $table) {
      db_create_table($name, $table);
    }
  }
  else {
    // There is data, so there are no column changes. Drop all the
    // prior indexes and create all the new ones, except for all the
    // priors that exist unchanged.
    $table = _field_sql_storage_tablename($prior_field);
    $revision_table = _field_sql_storage_revision_tablename($prior_field);
    foreach ($prior_field['indexes'] as $name => $columns) {
      if (!isset($field['indexes'][$name]) || $columns != $field['indexes'][$name]) {
        $real_name = _field_sql_storage_indexname($field['field_name'], $name);
        db_drop_index($table, $real_name);
        db_drop_index($revision_table, $real_name);
      }
    }
    $table = _field_sql_storage_tablename($field);
    $revision_table = _field_sql_storage_revision_tablename($field);
    foreach ($field['indexes'] as $name => $columns) {
      if (!isset($prior_field['indexes'][$name]) || $columns != $prior_field['indexes'][$name]) {
        $real_name = _field_sql_storage_indexname($field['field_name'], $name);
        $real_columns = array();
        foreach ($columns as $column_name) {
          $real_columns[] = _field_sql_storage_columnname($field['field_name'], $column_name);
        }
        db_add_index($table, $real_name, $real_columns);
        db_add_index($revision_table, $real_name, $real_columns);
      }
    }
  }
  drupal_get_schema(NULL, TRUE);
}

/**
 * Implements hook_field_storage_delete_field().
 */
function field_sql_storage_field_storage_delete_field($field) {
  // Mark all data associated with the field for deletion.
  $field['deleted'] = 0;
  $table = _field_sql_storage_tablename($field);
  $revision_table = _field_sql_storage_revision_tablename($field);
  db_update($table)
    ->fields(array('deleted' => 1))
    ->execute();

  // Move the table to a unique name while the table contents are being deleted.
  $field['deleted'] = 1;
  $new_table = _field_sql_storage_tablename($field);
  $revision_new_table = _field_sql_storage_revision_tablename($field);
  db_rename_table($table, $new_table);
  db_rename_table($revision_table, $revision_new_table);
  drupal_get_schema(NULL, TRUE);
}

/**
 * Implements hook_field_storage_load().
 */
function field_sql_storage_field_storage_load($entity_type, $entities, $age, $fields, $options) {
  $etid = _field_sql_storage_etid($entity_type);
  $load_current = $age == FIELD_LOAD_CURRENT;

  foreach ($fields as $field_id => $ids) {
    $field = field_info_field_by_id($field_id);
    $field_name = $field['field_name'];
    $table = $load_current ? _field_sql_storage_tablename($field) : _field_sql_storage_revision_tablename($field);

    $query = db_select($table, 't')
      ->fields('t')
      ->condition('etid', $etid)
      ->condition($load_current ? 'entity_id' : 'revision_id', $ids, 'IN')
      ->condition('language', field_available_languages($entity_type, $field), 'IN')
      ->orderBy('delta');

    if (empty($options['deleted'])) {
      $query->condition('deleted', 0);
    }

    $results = $query->execute();

    $delta_count = array();
    foreach ($results as $row) {
      if (!isset($delta_count[$row->entity_id][$row->language])) {
        $delta_count[$row->entity_id][$row->language] = 0;
      }

      if ($field['cardinality'] == FIELD_CARDINALITY_UNLIMITED || $delta_count[$row->entity_id][$row->language] < $field['cardinality']) {
        $item = array();
        // For each column declared by the field, populate the item
        // from the prefixed database column.
        foreach ($field['columns'] as $column => $attributes) {
          $column_name = _field_sql_storage_columnname($field_name, $column);
          $item[$column] = $row->$column_name;
        }

        // Add the item to the field values for the entity.
        $entities[$row->entity_id]->{$field_name}[$row->language][] = $item;
        $delta_count[$row->entity_id][$row->language]++;
      }
    }
  }
}

/**
 * Implements hook_field_storage_write().
 */
function field_sql_storage_field_storage_write($entity_type, $entity, $op, $fields) {
  list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
  $etid = _field_sql_storage_etid($entity_type);

  foreach ($fields as $field_id) {
    $field = field_info_field_by_id($field_id);
    $field_name = $field['field_name'];
    $table_name = _field_sql_storage_tablename($field);
    $revision_name = _field_sql_storage_revision_tablename($field);

    $all_languages = field_available_languages($entity_type, $field);
    $field_languages = array_intersect($all_languages, array_keys((array) $entity->$field_name));

    // Delete and insert, rather than update, in case a value was added.
    if ($op == FIELD_STORAGE_UPDATE) {
      // Delete languages present in the incoming $entity->$field_name.
      // Delete all languages if $entity->$field_name is empty.
      $languages = !empty($entity->$field_name) ? $field_languages : $all_languages;
      if ($languages) {
        db_delete($table_name)
          ->condition('etid', $etid)
          ->condition('entity_id', $id)
          ->condition('language', $languages, 'IN')
          ->execute();
        if (isset($vid)) {
          db_delete($revision_name)
            ->condition('etid', $etid)
            ->condition('entity_id', $id)
            ->condition('revision_id', $vid)
            ->condition('language', $languages, 'IN')
            ->execute();
        }
      }
    }

    // Prepare the multi-insert query.
    $do_insert = FALSE;
    $columns = array('etid', 'entity_id', 'revision_id', 'bundle', 'delta', 'language');
    foreach ($field['columns'] as $column => $attributes) {
      $columns[] = _field_sql_storage_columnname($field_name, $column);
    }
    $query = db_insert($table_name)->fields($columns);
    if (isset($vid)) {
      $revision_query = db_insert($revision_name)->fields($columns);
    }

    foreach ($field_languages as $langcode) {
      $items = (array) $entity->{$field_name}[$langcode];
      $delta_count = 0;
      foreach ($items as $delta => $item) {
        // We now know we have someting to insert.
        $do_insert = TRUE;
        $record = array(
          'etid' => $etid,
          'entity_id' => $id,
          'revision_id' => $vid,
          'bundle' => $bundle,
          'delta' => $delta,
          'language' => $langcode,
        );
        foreach ($field['columns'] as $column => $attributes) {
          $record[_field_sql_storage_columnname($field_name, $column)] = isset($item[$column]) ? $item[$column] : NULL;
        }
        $query->values($record);
        if (isset($vid)) {
          $revision_query->values($record);
        }

        if ($field['cardinality'] != FIELD_CARDINALITY_UNLIMITED && ++$delta_count == $field['cardinality']) {
          break;
        }
      }
    }

    // Execute the query if we have values to insert.
    if ($do_insert) {
      $query->execute();
      if (isset($vid)) {
        $revision_query->execute();
      }
    }
  }
}

/**
 * Implements hook_field_storage_delete().
 *
 * This function deletes data for all fields for an entity from the database.
 */
function field_sql_storage_field_storage_delete($entity_type, $entity, $fields) {
  list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
  $etid = _field_sql_storage_etid($entity_type);

  foreach (field_info_instances($entity_type, $bundle) as $instance) {
    if (isset($fields[$instance['field_id']])) {
      $field = field_info_field_by_id($instance['field_id']);
      field_sql_storage_field_storage_purge($entity_type, $entity, $field, $instance);
    }
  }
}

/**
 * Implements hook_field_storage_purge().
 *
 * This function deletes data from the database for a single field on
 * an entity.
 */
function field_sql_storage_field_storage_purge($entity_type, $entity, $field, $instance) {
  list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
  $etid = _field_sql_storage_etid($entity_type);

  $table_name = _field_sql_storage_tablename($field);
  $revision_name = _field_sql_storage_revision_tablename($field);
  db_delete($table_name)
    ->condition('etid', $etid)
    ->condition('entity_id', $id)
    ->execute();
  db_delete($revision_name)
    ->condition('etid', $etid)
    ->condition('entity_id', $id)
    ->execute();
}

/**
 * Implements hook_field_storage_query().
 */
function field_sql_storage_field_storage_query($field_id, $conditions, $options) {
  $load_current = $options['age'] == FIELD_LOAD_CURRENT;

  $field = field_info_field_by_id($field_id);
  $field_name = $field['field_name'];
  $table = $load_current ? _field_sql_storage_tablename($field) : _field_sql_storage_revision_tablename($field);
  $field_columns = array_keys($field['columns']);

  // Build the query.
  $query = db_select($table, 't');
  $query->join('field_config_entity_type', 'e', 't.etid = e.etid');

  // Add conditions.
  foreach ($conditions as $condition) {
    // A condition is either a (column, value, operator) triple, or a
    // (column, value) pair with implied operator.
    @list($column, $value, $operator) = $condition;
    // Translate operator and value if needed.
    switch ($operator) {
      case 'STARTS_WITH':
        $operator = 'LIKE';
        $value = db_like($value) . '%';
        break;

      case 'ENDS_WITH':
        $operator = 'LIKE';
        $value = '%' . db_like($value);
        break;

      case 'CONTAINS':
        $operator = 'LIKE';
        $value = '%' . db_like($value) . '%';
        break;
    }
    // Translate field columns into prefixed db columns.
    if (in_array($column, $field_columns)) {
      $column = _field_sql_storage_columnname($field_name, $column);
    }
    // Translate entity types into numeric ids. Expressing the condition on the
    // local 'etid' column rather than the JOINed 'type' column avoids a
    // filesort.
    if ($column == 'type') {
      $column = 't.etid';
      if (is_array($value)) {
        foreach (array_keys($value) as $key) {
          $value[$key] = _field_sql_storage_etid($value[$key]);
        }
      }
      else {
        $value = _field_sql_storage_etid($value);
      }
    }
    // Track condition on 'deleted'.
    if ($column == 'deleted') {
      $condition_deleted = TRUE;
    }

    $query->condition($column, $value, $operator);
  }

  // Exclude deleted data unless we have a condition on it.
  if (!isset($condition_deleted)) {
    $query->condition('deleted', 0);
  }

  // For a count query, return the count now.
  if ($options['count']) {
    return $query
      ->fields('t', array('etid', 'entity_id', 'revision_id'))
      ->distinct()
      ->countQuery()
      ->execute()
      ->fetchField();
  }

  // For a data query, add fields.
  $query
    ->fields('t', array('bundle', 'entity_id', 'revision_id'))
    ->fields('e', array('type'))
    // We need to ensure entities arrive in a consistent order for the
    // range() operation to work.
    ->orderBy('t.etid')
    ->orderBy('t.entity_id');

  // Initialize results array
  $return = array();

  // Getting $count entities possibly requires reading more than $count rows
  // since fields with multiple values span over several rows. We query for
  // batches of $count rows until we've either read $count entities or received
  // less rows than asked for.
  $entity_count = 0;
  do {
    if ($options['limit'] != FIELD_QUERY_NO_LIMIT) {
      $query->range($options['cursor'], $options['limit']);
    }
    $results = $query->execute();

    $row_count = 0;
    foreach ($results as $row) {
      $row_count++;
      $options['cursor']++;
      // If querying all revisions and the entity type has revisions, we need
      // to key the results by revision_ids.
      $entity_type = entity_get_info($row->type);
      $id = ($load_current || empty($entity_type['entity keys']['revision'])) ? $row->entity_id : $row->revision_id;

      if (!isset($return[$row->type][$id])) {
        $return[$row->type][$id] = entity_create_stub_entity($row->type, array($row->entity_id, $row->revision_id, $row->bundle));
        $entity_count++;
      }
    }
  } while ($options['limit'] != FIELD_QUERY_NO_LIMIT && $row_count == $options['limit'] && $entity_count < $options['limit']);

  // The query is complete when the last batch returns less rows than asked
  // for.
  if ($row_count < $options['limit']) {
    $options['cursor'] = FIELD_QUERY_COMPLETE;
  }

  return $return;
}

/**
 * Implements hook_field_storage_delete_revision().
 *
 * This function actually deletes the data from the database.
 */
function field_sql_storage_field_storage_delete_revision($entity_type, $entity, $fields) {
  list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
  $etid = _field_sql_storage_etid($entity_type);

  if (isset($vid)) {
    foreach ($fields as $field_id) {
      $field = field_info_field_by_id($field_id);
      $revision_name = _field_sql_storage_revision_tablename($field);
      db_delete($revision_name)
        ->condition('etid', $etid)
        ->condition('entity_id', $id)
        ->condition('revision_id', $vid)
        ->execute();
    }
  }
}

/**
 * Implements hook_field_storage_delete_instance().
 *
 * This function simply marks for deletion all data associated with the field.
 */
function field_sql_storage_field_storage_delete_instance($instance) {
  $etid = _field_sql_storage_etid($instance['entity_type']);
  $field = field_info_field($instance['field_name']);
  $table_name = _field_sql_storage_tablename($field);
  $revision_name = _field_sql_storage_revision_tablename($field);
  db_update($table_name)
    ->fields(array('deleted' => 1))
    ->condition('etid', $etid)
    ->condition('bundle', $instance['bundle'])
    ->execute();
  db_update($revision_name)
    ->fields(array('deleted' => 1))
    ->condition('etid', $etid)
    ->condition('bundle', $instance['bundle'])
    ->execute();
}

/**
 * Implements hook_field_attach_rename_bundle().
 */
function field_sql_storage_field_attach_rename_bundle($entity_type, $bundle_old, $bundle_new) {
  $etid = _field_sql_storage_etid($entity_type);
  // We need to account for deleted or inactive fields and instances.
  $instances = field_read_instances(array('entity_type' => $entity_type, 'bundle' => $bundle_new), array('include_deleted' => TRUE, 'include_inactive' => TRUE));
  foreach ($instances as $instance) {
    $field = field_info_field_by_id($instance['field_id']);
    if ($field['storage']['type'] == 'field_sql_storage') {
      $table_name = _field_sql_storage_tablename($field);
      $revision_name = _field_sql_storage_revision_tablename($field);
      db_update($table_name)
        ->fields(array('bundle' => $bundle_new))
        ->condition('etid', $etid)
        ->condition('bundle', $bundle_old)
        ->execute();
      db_update($revision_name)
        ->fields(array('bundle' => $bundle_new))
        ->condition('etid', $etid)
        ->condition('bundle', $bundle_old)
        ->execute();
    }
  }
}

/**
 * Implements hook_field_storage_purge_field().
 *
 * All field data items and instances have already been purged, so all
 * that is left is to delete the table.
 */
function field_sql_storage_field_storage_purge_field($field) {
  $table_name = _field_sql_storage_tablename($field);
  $revision_name = _field_sql_storage_revision_tablename($field);
  db_drop_table($table_name);
  db_drop_table($revision_name);
}

/**
 * Implements hook_field_storage_details().
 */
function field_sql_storage_field_storage_details($field) {
  $details = array();

  // Add field columns.
  foreach ((array) $field['columns'] as $column_name => $attributes) {
    $real_name = _field_sql_storage_columnname($field['field_name'], $column_name);
    $columns[$column_name] = $real_name;
  }
  return array(
    'sql' => array(
      FIELD_LOAD_CURRENT => array(
        _field_sql_storage_tablename($field) => $columns,
      ),
      FIELD_LOAD_REVISION => array(
        _field_sql_storage_revision_tablename($field) => $columns,
      ),
    ),
  );
}
