tripal_chado/src/Plugin/TripalStorage/ChadoStorage.php
<?php
namespace Drupal\tripal_chado\Plugin\TripalStorage;
use Drupal\tripal\TripalStorage\TripalStorageBase;
use Drupal\tripal\TripalStorage\Interfaces\TripalStorageInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\tripal\Services\TripalLogger;
use Drupal\tripal\Services\TripalEntityLookup;
use Drupal\tripal_chado\Database\ChadoConnection;
use Drupal\tripal_chado\Services\ChadoFieldDebugger;
use Drupal\tripal\TripalStorage\StoragePropertyValue;
use Drupal\tripal_chado\TripalStorage\ChadoRecords;
use Drupal\Core\Render\Element\Token;
/**
* Chado implementation of the TripalStorageInterface.
*
* @TripalStorage(
* id = "chado_storage",
* label = @Translation("Chado Storage"),
* description = @Translation("Interfaces with GMOD Chado for field values."),
* )
*/
class ChadoStorage extends TripalStorageBase implements TripalStorageInterface {
/**
* The database connection for querying Chado.
*
* @var \Drupal\tripal_chado\Database\ChadoConnection
*/
protected $connection;
/**
* A service to provide debugging for fields to developers.
*
* @var \Drupal\tripal_chado\Services\ChadoFieldDebugger
*/
protected $field_debugger;
/**
* Holds an instance of ChadoRecords.
*
* @var \Drupal\tripal_chado\TripalStorage\ChadoRecords
*/
protected $records = NULL;
/**
* Implements ContainerFactoryPluginInterface->create().
*
* Since we have implemented the ContainerFactoryPluginInterface this static function
* will be called behind the scenes when a Plugin Manager uses createInstance(). Specifically
* this method is used to determine the parameters to pass to the constructor.
*
* @param \Symfony\Component\DependencyInjection\ContainerInterface $container
* @param array $configuration
* @param string $plugin_id
* @param mixed $plugin_definition
*
* @return static
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('tripal.logger'),
$container->get('tripal_chado.database'),
$container->get('tripal_chado.field_debugger')
);
}
/**
* Implements __construct().
*
* Since we have implemented the ContainerFactoryPluginInterface, the constructor
* will be passed additional parameters added by the create() function. This allows
* our plugin to use dependency injection without our plugin manager service needing
* to worry about it.
*
* @param array $configuration
* @param string $plugin_id
* @param mixed $plugin_definition
* @param \Drupal\tripal\Services\TripalLogger $logger
* @param \Drupal\tripal_chado\Database\ChadoConnection $connection
* @param \Drupal\tripal_chado\Services\ChadoFieldDebugger $field_debugger
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, TripalLogger $logger, ChadoConnection $connection, ChadoFieldDebugger $field_debugger) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $logger);
$this->connection = $connection;
$this->field_debugger = $field_debugger;
}
/**
* @{inheritdoc}
*/
public function addFieldDefinition(string $field_name, object $field_definition) {
parent::addFieldDefinition($field_name, $field_definition);
// Now check if the field debugger should be enabled for this particular field.
$settings = $field_definition->getSettings();
if (array_key_exists('debug', $settings) AND $settings['debug']) {
$this->field_debugger->addFieldToDebugger($field_name);
$this->logger->notice('Debugging has been enabled for @name field.',
['@name' => $field_name],
['drupal_set_message' => TRUE, 'logger' => FALSE]
);
}
}
/**
*
* {@inheritDoc}
* @see \Drupal\tripal\TripalStorage\Interfaces\TripalStorageInterface::getStoredTypes()
*/
public function getStoredTypes() {
$ret_types = [];
foreach ($this->property_types as $field_name => $keys) {
$field_definition = $this->field_definitions[$field_name];
foreach ($keys as $key => $prop_type) {
$storage_settings = $prop_type->getStorageSettings();
// We always need to retreive any field that store a base record id
// a primery key or a foreign key link.
if (($storage_settings['action'] == 'store_id') or
($storage_settings['action'] == 'store_pkey') or
($storage_settings['action'] == 'store_link')) {
$ret_types[$field_name][$key] = $prop_type;
}
// For any other fields that have a 'drupal_store' set we need
// those too.
elseif ((array_key_exists('drupal_store', $storage_settings)) and
($storage_settings['drupal_store'] === TRUE)) {
$ret_types[$field_name][$key] = $prop_type;
}
}
}
return $ret_types;
}
/**
* @{inheritdoc}
*/
public function insertValues(&$values) : bool {
// Setup field debugging.
$this->field_debugger->printHeader('Insert');
$this->field_debugger->summarizeChadoStorage($this, 'At the beginning of ChadoStorage::insertValues');
// Build the ChadoRecords object.
$this->records = new ChadoRecords($this->field_debugger, $this->logger, $this->connection);
$this->buildChadoRecords($values);
$transaction_chado = $this->connection->startTransaction();
try {
// First: Insert the base table records.
$base_tables = $this->records->getBaseTables();
foreach ($base_tables as $base_table) {
$this->records->insertRecords($base_table, $base_table);
}
// Second: Insert records from the ancillary tables of
// each base table.
foreach ($base_tables as $base_table) {
$tables = $this->records->getAncillaryTables($base_table);
foreach ($tables as $table_alias) {
$this->records->insertRecords($base_table, $table_alias);
}
}
// Now that we've done the inserts, set the property values.
$this->setPropValues($values, $this->records);
}
catch (\Exception $e) {
$transaction_chado->rollback();
throw new \Exception($e);
}
return TRUE;
}
/**
* @{inheritdoc}
*/
public function updateValues(&$values) : bool {
// Setup field debugging.
$this->field_debugger->printHeader('Update');
$this->field_debugger->summarizeChadoStorage($this, 'At the beginning of ChadoStorage::updateValues');
// Build the ChadoRecords object.
$this->records = new ChadoRecords($this->field_debugger, $this->logger, $this->connection);
$this->buildChadoRecords($values);
$transaction_chado = $this->connection->startTransaction();
try {
// Handle base table records first.
$base_tables = $this->records->getBaseTables();
foreach ($base_tables as $base_table) {
$this->records->updateRecords($base_table, $base_table);
}
// Next delete all non base records so we can replace them
// with updates. This is necessary because we may violate unique
// constraints if we don't e.g. changing the order of records with a
// rank.
foreach ($base_tables as $base_table) {
$tables = $this->records->getAncillaryTables($base_table);
foreach ($tables as $table_alias) {
$this->records->deleteRecords($base_table, $table_alias, TRUE);
}
}
// Now insert all new values for the non-base table records.
foreach ($base_tables as $base_table) {
$tables = $this->records->getAncillaryTables($base_table);
foreach ($tables as $table_alias) {
$this->records->insertRecords($base_table, $table_alias);
}
}
// Now that we've done the updates, set the property values.
$this->setPropValues($values, $this->records);
}
catch (\Exception $e) {
$transaction_chado->rollback();
throw new \Exception($e);
}
return TRUE;
}
/**
* @{inheritdoc}
*/
public function loadValues(&$values) : bool {
// Setup field debugging.
$this->field_debugger->printHeader('Load');
$this->field_debugger->summarizeChadoStorage($this, 'At the beginning of ChadoStorage::loadValues');
// Build the ChadoRecords object.
$this->records = new ChadoRecords($this->field_debugger, $this->logger, $this->connection);
$this->buildChadoRecords($values);
$transaction_chado = $this->connection->startTransaction();
try {
$base_tables = $this->records->getBaseTables();
foreach ($base_tables as $base_table) {
// Do the select for the base tables
$this->records->selectRecords($base_table, $base_table);
// Then do the selects for the ancillary tables.
$tables = $this->records->getAncillaryTables($base_table);
foreach ($tables as $table_alias) {
$this->records->selectRecords($base_table, $table_alias);
}
}
$this->setPropValues($values, $this->records);
}
catch (\Exception $e) {
$transaction_chado->rollback();
throw new \Exception($e);
}
$this->field_debugger->reportValues($values, 'The values after loading is complete.');
return TRUE;
}
/**
* @{inheritdoc}
*/
public function deleteValues($values) : bool {
$this->field_debugger->printHeader('Delete');
$this->field_debugger->summarizeChadoStorage($this, 'At the beginning of ChadoStorage::deleteValues');
$this->records = new ChadoRecords($this->field_debugger, $this->logger, $this->connection);
return FALSE;
}
/**
* @{inheritdoc}
*/
public function findValues($values) {
// Setup field debugging.
$this->field_debugger->printHeader('Find');
$this->field_debugger->summarizeChadoStorage($this, 'At the beginning of ChadoStorage::findValues');
// Build the ChadoRecords object.
$this->records = new ChadoRecords($this->field_debugger, $this->logger, $this->connection);
$this->buildChadoRecords($values, TRUE);
// Start an array to keep track of the results we find.
// Each element in this array will be a clone of the full $values array
// passed into this method with it's propertyValue objects set to match
// the values in chado for a single record.
$found_list = [];
// Find all of property values for the base record. We need to
// search for this first because the properties that have values
// from linked tables need to know the record_id.
$transaction_chado = $this->connection->startTransaction();
try {
$base_tables = $this->records->getBaseTables();
foreach ($base_tables as $base_table) {
// First we find all matching base records.
$matches = $this->records->findRecords($base_table, $base_table);
// Now for each matching base record we need to select
// the ancillary tables.
foreach ($matches as $match) {
// Clone the value array for this match.
$new_values = $this->cloneValues($values);
// Iterate through tables that have conditions. We don't want to
// query tables that only have a condition with a link to the base
// table because these records aren't providing any filters to limit
// the base records.
$tables = $this->records->getAncillaryTablesWithCond($base_table);
$found_match = TRUE;
foreach ($tables as $table_alias) {
$num_found = $match->selectRecords($base_table, $table_alias);
// In order for a set of records to be considered found it must
// match all criteria, which means all ancillary tables must
// return results.
// @todo: when we need more fancy querying where we can set
// "or" clauses then this will need to be adjust. For now, we
// only use findValues() for publishing and in this case all
// criteria must be met.
if ($num_found == 0) {
$found_match = FALSE;
continue;
}
// Add any additional items to the values array that are needed.
$num_items = $match->getNumTableItems($base_table, $table_alias);
for ($i = 0; $i < $num_items - 1; $i++) {
$table_fields = $match->getTableFields($base_table, $table_alias);
foreach ($table_fields as $field_name) {
$this->addEmptyValuesItem($new_values, $field_name);
}
}
}
// Now set the values.
if ($found_match) {
$this->setPropValues($new_values, $match);
$found_list[] = $new_values;
}
}
}
}
catch (\Exception $e) {
$transaction_chado->rollback();
throw new \Exception($e);
}
return $found_list;
}
/**
* Sets the property values using the records returned from Chado.
*
* @param array $values
* Array of \Drupal\tripal\TripalStorage\StoragePropertyValue objects.
*
* @param ChadoRecords $records
* An instance of a ChadoRecords object from which values will be pulled.
* We don't use the built in member variable and instead allow it to
* be passed in because the findValues() function can generate copies
* of the $records array and use that to set multiple values.
*/
protected function setPropValues(&$values, ChadoRecords $records) {
$schema = $this->connection->schema();
$replace = [];
$function = [];
// Iterate through the value objects.
foreach ($values as $field_name => $deltas) {
// Retrieve the field configuration.
$definition = $this->getFieldDefinition($field_name);
foreach ($deltas as $delta => $keys) {
foreach ($keys as $key => $info) {
// Get the Property type for this value.
$prop_type = $this->getPropertyType($field_name, $key);
// Get important settings from the field configuration.
$field_settings = $definition->getSettings();
$storage_plugin_settings = $field_settings['storage_plugin_settings'];
$prop_storage_settings = $prop_type->getStorageSettings();
$action = $prop_storage_settings['action'];
// Get the values of properties that can be stored.
if ($action == 'replace') {
$replace[] = [$field_name, $delta, $key, $info];
}
else if ($action == 'function') {
// Create a context array to pass information to the callback function.
$context = [
'field_name' => $field_name,
'delta' => $delta,
'key' => $key,
'info' => $info,
'prop_type' => $prop_type,
'field_settings' => $field_settings,
];
$function[] = $context;
}
else {
// Parse the path.
$base_table = $storage_plugin_settings['base_table'];
$path = $prop_storage_settings['path'];
$as = array_key_exists('as', $prop_storage_settings) ? $prop_storage_settings['as'] : '';
$table_alias_mapping = array_key_exists('table_alias_mapping', $prop_storage_settings) ? $prop_storage_settings['table_alias_mapping'] : [];
$path_array = $this->parsePath($field_name, $base_table, $path, $table_alias_mapping, $as);
// Get the value column information for this property.
$base_table = $storage_plugin_settings['base_table'];
$value_col_info = $this->getPathValueColumn($path_array);
$table_alias = $value_col_info['table_alias'];
$column_alias = $value_col_info['column_alias'];
// For values that come from joins, we need to use the root table
// becuase this is the table that will have the value.
$my_delta = $delta;
if($action == 'read_value' and array_key_exists('join', $path_array)) {
$root_alias = $value_col_info['root_alias'];
$table_alias = $root_alias;
}
// Anytime we need to pull data from the base table, the delta
// should always be zero. There will only ever be one base record.
if ($table_alias == $base_table) {
$my_delta = 0;
}
// Set the value.
$value = $records->getColumnValue($base_table, $table_alias, $my_delta, $column_alias);
$values[$field_name][$delta][$key]['value']->setValue($value);
}
}
}
}
// Now that we have all stored and loaded values set, let's do any
// replacements.
foreach ($replace as $item) {
$field_name = $item[0];
$delta = $item[1];
$key = $item[2];
$info = $item[3];
$prop_type = $this->getPropertyType($field_name, $key);
$prop_storage_settings = $prop_type->getStorageSettings();
$template = $prop_storage_settings['template'];
$matches = [];
$value = $template;
if (preg_match_all('/\[(.*?)\]/', $template, $matches)) {
foreach ($matches[1] as $match) {
if (array_key_exists($match, $values[$field_name][$delta])) {
$match_value = $values[$field_name][$delta][$match]['value']->getValue() ?? '';
$value = preg_replace("/\[$match\]/", $match_value, $value);
}
}
}
if ($value !== NULL && is_string($value)) {
$values[$field_name][$delta][$key]['value']->setValue(trim($value));
}
else {
$values[$field_name][$delta][$key]['value']->setValue($value);
}
}
// Lastly, let's call any functions.
foreach ($function as $context) {
// Add current values to the context so that a function
// can access other non-function fields if it needs to.
$context['values'] = $values;
// Retrieve the needed keys for the $values array
$field_name = $context['field_name'];
$delta = $context['delta'];
$key = $context['key'];
// Retrieve the call back function
$prop_storage_settings = $context['prop_type']->getStorageSettings();
$namespace = $prop_storage_settings['namespace'];
$callback_function = $prop_storage_settings['function'];
// Validate the callback function and then call it to generate a value.
$value = NULL;
if (method_exists($namespace, $callback_function)) {
$value = call_user_func($namespace . '::' . $callback_function, $context);
}
else {
$this->logger->error('Callback function for field @field does not exist: @namespace::@function.',
['@field' => $field_name, '@namespace' => $namespace, '@function' => $callback_function]
);
}
if ($value !== NULL && is_string($value)) {
$values[$field_name][$delta][$key]['value']->setValue(trim($value));
}
else {
$values[$field_name][$delta][$key]['value']->setValue($value);
}
}
}
/**
* Indexes a values array for easy lookup.
*
* @param array $values
* Associative array 5-levels deep.
* The 1st level is the field name (e.g. ncbitaxon__common_name).
* The 2nd level is the delta value (e.g. 0).
* The 3rd level is a field key name (i.e. record_id and value).
* The 4th level must contain the following three keys/value pairs
* - "value": a \Drupal\tripal\TripalStorage\StoragePropertyValue object
* - "type": a\Drupal\tripal\TripalStorage\StoragePropertyType object
* - "definition": a \Drupal\Field\Entity\FieldConfig object
* When the function returns, any values retrieved from the data store
* will be set in the StoragePropertyValue object.
* @param bool $is_find
* Set to TRUE if we are building the record array for finding records.
*/
protected function buildChadoRecords($values, bool $is_find = FALSE) {
$this->field_debugger->reportValues($values, 'The values submitted to ChadoStorage');
// Iterate through the value objects.
foreach ($values as $field_name => $deltas) {
// Retrieve the field configuration.
$definition = $this->getFieldDefinition($field_name);
if (!is_object($definition)) {
$this->logger->error($this->t('Cannot save record in Chado. The field, "@field", is missing the field definition (i.e. FieldConfig object).',
['@field' => $field_name]));
continue;
}
$storage_plugin_settings = $definition->getSettings()['storage_plugin_settings'];
foreach ($deltas as $delta => $keys) {
foreach ($keys as $key => $info) {
// Ensure we have a value to work with.
if (!array_key_exists('value', $info) OR !is_object($info['value'])) {
$this->logger->error($this->t('Cannot save record in Chado. The field, "@field", is missing the StoragePropertyValue object.',
['@field' => $field_name]));
continue;
}
// Retrieve the property type for this value.
$prop_value = $info['value'];
$prop_type = $this->getPropertyType($field_name, $key);
$prop_storage_settings = $prop_type->getStorageSettings();
// Make sure we have an action for this property.
if (!array_key_exists('action', $prop_storage_settings)) {
$this->logger->error($this->t('Cannot store the property, @field.@prop ("@label"), in Chado. The property is missing an action in the property settings: @settings',
['@field' => $field_name, '@prop' => $key,
'@label' => $definition->getLabel(), '@settings' => print_r($prop_storage_settings, TRUE)]));
continue;
}
$action = $prop_storage_settings['action'];
// Check that the base table for the field is set.
if (!array_key_exists('base_table', $storage_plugin_settings)) {
$this->logger->error($this->t('Cannot store the property, @field.@prop, in Chado. The field is missing the chado base table name.',
['@field' => $field_name, '@prop' => $key]));
continue;
}
// Define the context array which will contain all details needed
// for the buildChadoRecords() methods.
$base_table = $storage_plugin_settings['base_table'];
$context = [];
$context['is_find'] = $is_find;
$context['base_table'] = $base_table;
$context['root_table'] = $base_table;
$context['root_alias'] = $base_table;
$context['operation'] = array_key_exists('operation', $info) ? $info['operation'] : '=';
$context['field_name'] = $field_name;
$context['property_key'] = $key;
$context['property_settings'] = $prop_storage_settings;
$context['delta'] = $delta;
$context['action'] = $action;
// Get the path array for this field and add any joins if any are needed.
if (array_key_exists('path', $prop_storage_settings)) {
// First parse the path
$path = $prop_storage_settings['path'];
$as = array_key_exists('as', $prop_storage_settings) ? $prop_storage_settings['as'] : '';
$table_alias_mapping = array_key_exists('table_alias_mapping', $prop_storage_settings) ? $prop_storage_settings['table_alias_mapping'] : [];
$path_array = $this->parsePath($field_name, $base_table, $path, $table_alias_mapping, $as);
// The path will have the root table. This may or may not be the
// same as the base table so we should track it.
$context['root_table'] = $path_array['root_table'];
$context['root_alias'] = $path_array['root_alias'];
// We only add joins when the action is 'read_value' because
// they guarantee a single value (meaning a 1:1 join). For
// other joins there may be a many to one so we don't want to add
// those joins off the base table.
if ($action == 'read_value' and array_key_exists('join', $path_array)) {
$this->handleJoins($path_array, $context);
}
// Add to the context.
$context['path_string'] = $prop_storage_settings['path'];
$context['path_array'] = $path_array;
}
// Now for each action type, set the conditions and fields for
// selecting chado records based on the other properties supplied.
switch ($action) {
case 'store_id':
$this->handleStoreID($context, $prop_value);
break;
case 'store_pkey':
$this->handleStorePkey($context, $prop_value);
break;
case 'store_link':
$this->handleStoreLink($context, $prop_value);
break;
case 'store':
$this->handleStore($context, $prop_value);
break;
case 'read_value':
$this->handleReadValue($context, $prop_value);
break;
case 'replace':
// Do nothing here for properties that need replacement
// since the values are provided by other properties.
break;
case 'function':
// Do nothing here for properties that require post-processing
// with a function as determining the value is handled by
// the function not by chadostorage.
break;
}
}
}
}
// Set some debugging info.
$this->field_debugger->summarizeBuiltRecords($this->records);
}
/**
* A helper function for the buildChadoRecords() function.
*
* Add chado record information for a specific ChadoStorageProperty
* where the action is store_id.
*
* STORE ID: stores the primary key value for a core table in chado.
*
* Note: There may be more core tables in properties for this field
* then just the base table. For example, a field involving a two-join
* linker table will include two core tables.
*
* @param array $context
* The field/property context provided by the buildChadoRecords() function.
* @param StoragePropertyValue $prop_value
* The value object for the property we are adding records for.
* Note: We will always have a StoragePropertyValue for a property even if
* the value is not set. This method is expected to check if the value is empty or not.
*/
protected function handleStoreID(array $context, StoragePropertyValue $prop_value) {
$base_table = $context['base_table'];
$record_id = $prop_value->getValue();
$value_col_info = $this->getPathValueColumn($context['path_array']);
$elements = [
'base_table' => $base_table,
'root_table' => $context['root_table'],
'root_alias' => $context['root_alias'],
'chado_table' => $value_col_info['chado_table'],
'table_alias' => $value_col_info['table_alias'],
'chado_column' => $value_col_info['chado_column'],
'column_alias' => $value_col_info['column_alias'],
'delta' => $context['delta'],
'value' => $record_id > 0 ? $record_id : NULL,
'operation' => $context['operation'],
'field_name' => $context['field_name'],
'property_key' => $context['property_key'],
];
// The store_id action should only be used for the base table...
// @todo: I think these checks should go into a field validation test rather than here.
if ($elements['chado_table'] !== $elements['base_table']) {
$this->logger->error($this->t('The @field.@key property type uses the '
. 'store_id action type but is not associated with the base table of the field. '
. 'Either change the base_table of this field or use store_pkey instead. @chado_table != @base_table',
['@field' => $context['field_name'],
'@key' => $context['property_key'],
'@base_table' => $elements['base_table'],
'@chado_table' => $elements['chado_table']
]));
}
// Now determine the primary key for the chado table.
$chado_table_def = $this->connection->schema()->getTableDef($elements['chado_table'], ['format' => 'drupal']);
$chado_table_pkey = $chado_table_def['primary key'];
if ($elements['chado_column'] !== $chado_table_pkey) {
$this->logger->error($this->t('The @field.@key property type uses the '
. 'store_id action and the column specified in the "path" settings is not '
. 'the primary key for base table. ',
['@field' => $context['field_name'], '@key' => $context['property_key']]));
}
// If this is a store_id then we're storing the base table record.
$this->records->addColumn($elements, TRUE);
// Set the field and the condition if we have a record_id.
if ($record_id > 0) {
$this->records->addCondition($elements);
}
}
/**
* A helper function for the buildChadoRecords() function.
*
* Add chado record information for a specific ChadoStorageProperty
* where the action is store_pkey.
*
* STORE PKEY: stores the primary key value of a linking table.
*
* NOTE: A linking table is not a core table. This is important because
* during insert and update, the core tables are handled first and then
* linking tables are handled after.
*
* @param array $context
* The field/property context provided by the buildChadoRecords() function.
* @param StoragePropertyValue $prop_value
* The value object for the property we are adding records for.
* Note: We will always have a StoragePropertyValue for a property even if
* the value is not set. This method is expected to check if the value is empty or not.
*/
protected function handleStorePkey(array $context, StoragePropertyValue $prop_value) {
$value_col_info = $this->getPathValueColumn($context['path_array']);
$pkey_id = $prop_value->getValue();
$elements = [
'base_table' => $context['base_table'],
'root_table' => $context['root_table'],
'root_alias' => $context['root_alias'],
'chado_table' => $value_col_info['chado_table'],
'chado_column' => $value_col_info['chado_column'],
'table_alias' => $value_col_info['table_alias'],
'column_alias' => $value_col_info['column_alias'],
'delta' => $context['delta'],
'value' => $pkey_id ? $pkey_id : NULL,
'field_name' => $context['field_name'],
'property_key' => $context['property_key'],
];
$this->records->addColumn($elements);
if ($pkey_id) {
$elements['operation'] = $context['operation'];
$this->records->addCondition($elements);
}
}
/**
*
* A helper function for the buildChadoRecords() function.
*
* Add chado record information for a specific ChadoStorageProperty
* where the action is store_link.
*
* STORE LINK: performs a join between two tables, one of which is a
* core table and one of which is a linking table. The value which is saved
* in this property is the left_table_id indicated in other key/value pairs.
*
* NOTE: A JOIN is not added to the query but rather this property stores
* the id that a join would normally look up. This is much more performant.
*
* @param array $context
* The field/property context provided by the buildChadoRecords() function.
* @param StoragePropertyValue $prop_value
* The value object for the property we are adding records for.
* Note: We will always have a StoragePropertyValue for a property even if
* the value is not set. This method is expected to check if the value is empty or not.
*/
protected function handleStoreLink(array $context, StoragePropertyValue $prop_value) {
$base_table = $context['base_table'];
$value_col_info = $this->getPathValueColumn($context['path_array']);
$link_id = $this->records->getRecordID($base_table);
$elements = [
'base_table' => $base_table,
'root_table' => $context['root_table'],
'root_alias' => $context['root_alias'],
'chado_table' => $value_col_info['chado_table'],
'table_alias' => $value_col_info['table_alias'],
'chado_column' => $value_col_info['chado_column'],
'column_alias' => $value_col_info['column_alias'],
// Setting the value to NULL and indicating this field contains a link
// to the base table will cause the value to be set automatically by
// ChadoRecord once it's available.
'value' => $link_id ? $link_id : NULL,
'operation' => $context['operation'],
'delta' => $context['delta'],
'field_name' => $context['field_name'],
'property_key' => $context['property_key'],
];
$this->records->addColumn($elements, TRUE);
$this->records->addCondition($elements);
}
/**
*
* A helper function for the buildChadoRecords() function.
*
* Add chado record information for a specific ChadoStorageProperty
* where the action is store.
*
* STORE: indicates that the value of this property can be loaded and
* stored in the Chado table indicated by this property.
*
* @param array $context
* The field/property context provided by the buildChadoRecords() function.
* @param StoragePropertyValue $prop_value
* The value object for the property we are adding records for.
* Note: We will always have a StoragePropertyValue for a property even if
* the value is not set. This method is expected to check if the value is empty or not.
*/
protected function handleStore(array $context, StoragePropertyValue $prop_value) {
$value_col_info = $this->getPathValueColumn($context['path_array']);
$value = $prop_value->getValue();
if (is_string($value)) {
$value = trim($value);
}
$elements = [
'base_table' => $context['base_table'],
'root_table' => $context['root_table'],
'root_alias' => $context['root_alias'],
'chado_table' => $value_col_info['chado_table'],
'chado_column' => $value_col_info['chado_column'],
'table_alias' => $value_col_info['table_alias'],
'column_alias' => $value_col_info['column_alias'],
'delta' => $context['delta'],
'value' => $value,
'operation' => $context['operation'],
'delete_if_empty' => array_key_exists('delete_if_empty', $context['property_settings']) ? $context['property_settings']['delete_if_empty'] : FALSE,
'empty_value' => array_key_exists('empty_value', $context['property_settings']) ? $context['property_settings']['empty_value'] : '',
'field_name' => $context['field_name'],
'property_key' => $context['property_key'],
];
$this->records->addColumn($elements);
// If this is a find operation then we want to add a condition.
if ($context['is_find'] and $value !== NULL) {
$this->records->addCondition($elements);
}
}
/**
* A helper function for the buildChadoRecords() function.
*
* Add chado record information for a specific ChadoStorageProperty
* where the action is read_value.
*
* READ_VALUE: selecting a single column. This cannot be used for inserting or
* updating values. Instead we use store actions for that.
* If reading a value from a non-base table, then the path should
* be provided.
*
* @param array $context
* The field/property context provided by the buildChadoRecords() function.
* @param StoragePropertyValue $prop_value
* The value object for the property we are adding records for.
* Note: We will always have a StoragePropertyValue for a property even if
* the value is not set. This method is expected to check if the value is empty or not.
*/
protected function handleReadValue(array $context, StoragePropertyValue $prop_value) {
// Adding of fields via a join are handled by the ChadoRecord::setJoin() functino.
if (array_key_exists('join', $context['path_array'])) {
return;
}
$value = $prop_value->getValue();
if (is_string($value)) {
$value = trim($value);
}
$value_col_info = $this->getPathValueColumn($context['path_array']);
$elements = [
'base_table' => $context['base_table'],
'root_table' => $context['root_table'],
'root_alias' => $context['root_alias'],
'chado_table' => $value_col_info['root_table'],
'table_alias' => $value_col_info['root_alias'],
'chado_column' => $value_col_info['chado_column'],
'column_alias' => $value_col_info['column_alias'],
'delta' => $context['delta'],
'value' => $value ? $value : NULL,
'operation' => $context['operation'],
'field_name' => $context['field_name'],
'property_key' => $context['property_key'],
];
$this->records->addColumn($elements, FALSE, TRUE);
}
/**
* Takes a path string for a field property and converts it to an array structure.
*
* @param mixed $path
* A string continaining the path. Note: this is a recursive function and on
* recursive calls this variable will be n array. Hence, the type is "mixed".*
* @param array $aliases
* Optional. The list of table aliases provdied by the `table_alias_mapping`
* argument of a field. If this variable is an empty array then the function
* will use the table name provided in the path.
* @param string $as
* An alias to be used for the Chado table column that contains the value. This
* argument will rename the column.
* @param string $full_path
* This argument is used by recursion to build the string path for each level.
* It should not be set by the callee.
* @return array
*
*/
protected function parsePath(string $field_name, string $base_table, mixed $path, array $aliases = [], string $as = '', string $full_path = '') {
// If the path is a string then split it.
$path_arr = [];
if (is_string($path)) {
// For sanity sake, remove any trailing semicolons that might be there by accident.
$trimmed_path = trim($path, ';');
$path_arr = explode(";", $trimmed_path);
}
if (is_array($path)) {
$path_arr = $path;
}
// Get the current path in the list.
$curr_path = array_shift($path_arr);
$full_path = $full_path ? $full_path . ';' . $curr_path : $curr_path;
// The root table is the table at the beginning of the path.
$root_alias = preg_replace('/^([^.;>]+?)\..*$/', '$1', $full_path);
$root_table = $root_alias;
if (array_key_exists($root_alias, $aliases)) {
$root_table = $aliases[$root_alias];
}
// If the path has a '>' then this is a join.
if (preg_match('/>/', $curr_path)) {
// Get the left column and the right table join infor.
list($left, $right) = explode(">", $curr_path);
list($left_alias, $left_column) = explode(".", $left);
list($right_alias, $right_column) = explode(".", $right);
// Get the true Chado tables from the alias array. Otherwise use
// the table provided. If the developer gave a bad Chado table or
// didn't provide a proper mapping to an alias, then an SQL error
// will occur. We don't check it here.
$left_table = $left_alias;
$right_table = $right_alias;
if (array_key_exists($left_alias, $aliases)) {
$left_table = $aliases[$left_alias];
}
if (array_key_exists($right_alias, $aliases)) {
$right_table = $aliases[$right_alias];
}
// Build the return array for the join.
$ret_array = [
'base_table' => $base_table,
'root_table' => $root_table,
'root_alias' => $root_alias,
'chado_table' => $left_table,
'table_alias' => $left_alias,
'join' => [
'base_table' => $base_table,
'root_table' => $root_table,
'root_alias' => $root_alias,
'path' => $full_path,
// The path string has no way to specify the type of join so
// we'll default it to an 'outer' join.
'type' => 'outer',
'chado_table' => $right_table,
'table_alias' => $right_alias,
'left_column' => $left_column,
'right_column' => $right_column,
],
];
// Before we return, let's check if we have more sub paths to process.
// if so, then recurse.
$sub_path_arr = [];
if (count($path_arr) > 0) {
$sub_path_arr = $this->parsePath($field_name, $base_table, $path_arr, $aliases, $as, $full_path);
}
// If there are no more joins, then we need to set the value column to be
// the same as the last column in tge join.
else {
$ret_array['join']['value_column'] = $right_column;
$ret_array['join']['value_alias'] = $as ? $as : $right_column;
}
// If we have a value column in the return value then this means that it hit
// the end of the join and the value column in which the value is stored
// will be at the end. We can just merge that information with the current
// return array.
if (array_key_exists('value_column', $sub_path_arr)) {
$ret_array['join'] = array_merge($ret_array['join'], $sub_path_arr);
}
// Otherwise this is another join.
else if (array_key_exists('chado_table', $sub_path_arr)) {
$ret_array['join']['join'] = $sub_path_arr['join'];
}
return $ret_array;
}
// If the path is not a join but has a period then this specifices
// the table and the column with the value.
else if (preg_match('/\./', $curr_path)) {
// Get the table/column at the end.
list($table_alias, $value_column) = explode(".", $path);
$chado_table = $table_alias;
if (array_key_exists($table_alias, $aliases)) {
$chado_table = $aliases[$table_alias];
}
// If the base table is not the same as the root table then
// we should add the field name to the colun alias. Otherwise
// we may have conflicts if mutiple fields use the same alias.
$value_alias = $as ? $as : $value_column;
if ($base_table != $root_table) {
$value_alias = $field_name . '__' . $value_alias;
}
return [
'path' => $full_path,
'base_table' => $base_table,
'root_table' => $root_table,
'root_alias' => $root_alias,
'chado_table' => $chado_table,
'table_alias' => $table_alias,
'value_column' => $value_column,
'value_alias' => $value_alias
];
}
// There is no period in the path so there is no Chado table. We are at the
// end of the path with joins and we can just return the value column.
else {
// If the base table is not the same as the root table then
// we should add the field name to the colun alias. Otherwise
// we may have conflicts if mutiple fields use the same alias.
$value_alias = $as ? $as : $curr_path;
$value_alias = $field_name . '__' . $value_alias;
return [
'base_table' => $base_table,
'root_table' => $root_table,
'root_alias' => $root_alias,
'value_column' => $curr_path,
'value_alias' => $value_alias
];
}
}
/**
* A helper function to quickly get the value column information from a path.
*
* @param array $path
* The parsed path of the field property.
*/
protected function getPathValueColumn(array $path) {
if (array_key_exists('value_column', $path)) {
return [
'base_table' => $path['base_table'],
'root_table' => $path['root_table'],
'root_alias' => $path['root_alias'],
'chado_table' => $path['chado_table'],
'table_alias' => $path['table_alias'],
'chado_column' => $path['value_column'],
'column_alias' => $path['value_alias'],
];
}
else if (array_key_exists('join', $path)) {
return $this->getPathValueColumn($path['join']);
}
// We shouldn't get here.
return NULL;
}
/**
* A helper function for the buildChadoRecords() function.
*
* Adds the joins to the ChadoRecord object.
*
* @param array $path_array
* The join path array
* @param array $context
* The field/property context provided by the buildChadoRecords() function.
*/
protected function handleJoins(array &$path_array, array $context) {
$elements = [
'base_table' => $context['base_table'],
'root_table' => $context['root_table'],
'root_alias' => $context['root_alias'],
'chado_table' => $path_array['root_table'],
'table_alias' => $path_array['root_alias'],
'delta' => $context['delta'],
'left_table' => $path_array['chado_table'],
'left_alias' => $path_array['table_alias'],
'left_column' => $path_array['join']['left_column'],
'right_table' => $path_array['join']['chado_table'],
'right_alias' => $path_array['join']['table_alias'],
'right_column' => $path_array['join']['right_column'],
'join_type' => $path_array['join']['type'],
'join_path' => $path_array['join']['path'],
];
$this->records->addJoin($elements);
$path_array['join']['table_alias'] = $elements['right_alias'];
// If there is another join then handle that.
if (array_key_exists('join', $path_array['join'])) {
$join_path = $path_array['join'];
$this->handleJoins($join_path, $context);
$path_array['join'] = $join_path;
}
// If we've reached the end of the joins we need to add the join columns.
if (array_key_exists('value_column', $path_array['join'])) {
$elements = [
'base_table' => $context['base_table'],
'root_table' => $context['root_table'],
'root_alias' => $context['root_alias'],
'chado_table' => $path_array['root_table'],
'table_alias' => $path_array['root_alias'],
'delta' => $context['delta'],
'join_path' => $path_array['join']['path'],
'chado_column' => $path_array['join']['value_column'],
'column_alias' => $path_array['join']['value_alias'],
'field_name' => $context['field_name'],
'property_key' => $context['property_key'],
];
$this->records->addJoinColumn($elements);
}
}
/**
*
* {@inheritDoc}
*/
public function validateValues($values) {
$this->field_debugger->printHeader('Validate');
$this->field_debugger->summarizeChadoStorage($this, 'At the beginning of ChadoStorage::validateValues');
// Build the ChadoRecord object.
$this->records = new ChadoRecords($this->field_debugger, $this->logger, $this->connection);
$this->buildChadoRecords($values);
// Validate the records.
$violations = $this->records->validate();
// Clear out the ChadoRecord object.
$this->records = NULL;
return $violations;
}
/**
*
* {@inheritDoc}
* @see \Drupal\tripal\TripalStorage\Interfaces\TripalStorageInterface::publishFrom()
*/
public function publishForm($form, FormStateInterface &$form_state) {
$chado_schemas = [];
$chado = \Drupal::service('tripal_chado.database');
foreach ($chado->getAvailableInstances() as $schema_name => $details) {
$chado_schemas[$schema_name] = $schema_name;
}
$default_chado = $chado->getSchemaName();
$storage_form['schema_name'] = [
'#type' => 'select',
'#title' => 'Chado Schema Name',
'#required' => TRUE,
'#description' => 'Select one of the installed Chado schemas to import into.',
'#options' => $chado_schemas,
'#default_value' => $default_chado,
];
return $storage_form;
}
/**
* A callback function to allow linking fields to include the Drupal entity ID.
*
* @param array $context
* Values that a callback function might need in order
* to calculate the field's final value.
*
* @return int
* The Drupal entity ID, or -1 if it doesn't exist.
* We use -1 because Tripal preSave will flag a zero for deletion.
*/
static public function drupalEntityIdLookupCallback($context) {
$lookup_manager = \Drupal::service('tripal.tripal_entity.lookup');
$delta = $context['delta'];
$field_name = $context['field_name'];
// Get the name of the primary key column of the Chado table that
// the entity is based on, which is a foreign key for whatever the
// current content type is. Because this callback handles all fields,
// it doesn't know what that is, so we need to have that saved in the
// field properties.
$prop_storage_settings = $context['prop_type']->getStorageSettings();
$fkey = $prop_storage_settings['fkey'] ?? NULL;
if (!$fkey) {
// Maybe throw an exception here so developers know they forgot the 'fkey'
return -1;
}
$record_id = $context['values'][$field_name][$delta][$fkey]['value'] ?? NULL;
if (!$record_id) {
return -1;
}
$record_id = $record_id->getValue('value');
// During publish, record_id may be null. In this particular case, return null.
if (!$record_id) {
return NULL;
}
// Given the Chado record ID and bundle term, we can lookup the Drupal entity ID.
$ftable = $prop_storage_settings['ftable'] ?? NULL;
$entity_id = $lookup_manager->getEntityId(
$record_id,
$context['field_settings']['termIdSpace'],
$context['field_settings']['termAccession'],
$ftable
);
// In the TripalEntity class, the preSave function will flag all falsey
// property values for deletion when drupal_store is set to TRUE.
// To get around this, indicate the lack of a Drupal entity with a -1.
if (!$entity_id) {
$entity_id = -1;
}
return $entity_id;
}
}