sparql_entity_storage-8.x-1.0-alpha8/src/Driver/Database/sparql/Connection.php
src/Driver/Database/sparql/Connection.php
<?php
declare(strict_types=1);
namespace Drupal\sparql_entity_storage\Driver\Database\sparql;
use Drupal\Core\Database\Event\StatementExecutionEndEvent;
use Drupal\Core\Database\Log;
use Drupal\Core\Database\StatementInterface;
use Drupal\sparql_entity_storage\Exception\SparqlQueryException;
use EasyRdf\Graph;
use EasyRdf\Http\Exception as EasyRdfException;
use EasyRdf\Sparql\Client;
use EasyRdf\Sparql\Result;
/**
* @addtogroup database
* @{
*/
/**
* SPARQL connection service.
*/
class Connection implements ConnectionInterface {
/**
* The client instance object that performs requests to the SPARQL endpoint.
*/
protected Client $easyRdfClient;
/**
* The connection information for this connection object.
*
* @var array
*/
protected array $connectionOptions;
/**
* The static cache of a DB statement stub object.
*/
protected StatementInterface $statementStub;
/**
* The database target this connection is for.
*
* We need this information for later auditing and logging.
*/
protected ?string $target = NULL;
/**
* The key representing this connection.
*
* The key is a unique string which identifies a database connection. A
* connection can be a single server or a cluster of primary and replicas
* (use target to pick between primary and replica).
*/
protected ?string $key = NULL;
/**
* The current database logging object for this connection.
*/
protected ?Log $logger = NULL;
/**
* Tracks the database API events to be dispatched.
*
* For performance reasons, database API events are not yielded by default.
* Call ::enableEvents() to enable them.
*/
private array $enabledEvents = [];
/**
* Constructs a new connection instance.
*
* @param \EasyRdf\Sparql\Client $easy_rdf_client
* Object of \EasyRdf\Sparql\Client which is a database connection.
* @param array $connection_options
* An associative array of connection options. See the "Database settings"
* section from 'sites/default/settings.php' a for a detailed description of
* the structure of this array.
*/
public function __construct(Client $easy_rdf_client, array $connection_options) {
$this->easyRdfClient = $easy_rdf_client;
$this->connectionOptions = $connection_options;
}
/**
* {@inheritdoc}
*/
public function getSparqlClient(): Client {
return $this->easyRdfClient;
}
/**
* {@inheritdoc}
*/
public function query(string $query, array $args = [], array $options = []): Result {
return $this->doQuery($query, $args, $options);
}
/**
* {@inheritdoc}
*/
public function constructQuery(string $query, array $args = [], array $options = []): Graph {
return $this->doQuery($query, $args, $options);
}
/**
* Execute the query against the endpoint.
*
* @param string $query
* The string query to execute.
* @param array $args
* An array of arguments for the query.
* @param array $options
* An associative array of options to control how the query is run.
*
* @return \EasyRdf\Sparql\Result|\EasyRdf\Graph
* The query result.
*
* @throws \InvalidArgumentException
* If $args value is passed but arguments replacement is not yet
* supported. To be removed in #55.
* @throws \Drupal\sparql_entity_storage\Exception\SparqlQueryException
* Exception during query execution, e.g. timeout.
*
* @see https://github.com/ec-europa/sparql_entity_storage/issues/1
*/
protected function doQuery(string $query, array $args = [], array $options = []) {
// @todo Remove this in #1.
// @see https://github.com/ec-europa/sparql_entity_storage/issues/1
if ($args) {
throw new \InvalidArgumentException('Replacement arguments are not yet supported.');
}
if ($this->logger) {
$query_start = microtime(TRUE);
}
try {
// @todo Implement argument replacement in #1.
// @see https://github.com/ec-europa/sparql_entity_storage/issues/1
$results = $this->easyRdfClient->query($query);
}
catch (EasyRdfException $exception) {
// Re-throw the exception, but with the query as message.
throw new SparqlQueryException("Execution of query failed with message '{$exception->getBody()}'. Query: " . $query, $exception->getCode(), $exception);
}
if ($this->logger) {
$query_end = microtime(TRUE);
$this->log($query, $args, $query_end - $query_start);
}
return $results;
}
/**
* {@inheritdoc}
*/
public function update(string $query, array $args = [], array $options = []): Result {
// @todo Remove this in #1.
// @see https://github.com/ec-europa/sparql_entity_storage/issues/1
if ($args) {
throw new \InvalidArgumentException('Replacement arguments are not yet supported.');
}
if ($this->logger) {
$query_start = microtime(TRUE);
}
try {
// @todo Implement argument replacement in #1.
// @see https://github.com/ec-europa/sparql_entity_storage/issues/1
$result = $this->easyRdfClient->update($query);
}
catch (EasyRdfException $exception) {
// Re-throw the exception, but with the query as message.
throw new SparqlQueryException("Execution of query failed with message '{$exception->getBody()}'. Query: " . $query, $exception->getCode(), $exception);
}
if ($this->logger) {
$query_end = microtime(TRUE);
$this->log($query, $args, $query_end - $query_start);
}
return $result;
}
/**
* {@inheritdoc}
*/
public function getQueryUri(): string {
return $this->easyRdfClient->getQueryUri();
}
/**
* {@inheritdoc}
*/
public function setLogger(Log $logger): void {
$this->logger = $logger;
}
/**
* {@inheritdoc}
*/
public function getLogger(): ?Log {
return $this->logger;
}
/**
* {@inheritdoc}
*/
public static function open(array &$connection_options = []): Client {
$endpoint_path = !empty($connection_options['database']) ? trim($connection_options['database'], ' /') : '';
// After trimming the value might be ''. Testing again.
$endpoint_path = $endpoint_path ?: 'sparql';
$protocol = empty($connection_options['https']) ? 'http' : 'https';
$connect_string = "{$protocol}://{$connection_options['host']}:{$connection_options['port']}/{$endpoint_path}";
return new Client($connect_string);
}
/**
* {@inheritdoc}
*/
public function setTarget(?string $target = NULL): void {
if (!isset($this->target)) {
$this->target = $target;
}
}
/**
* {@inheritdoc}
*/
public function getTarget(): ?string {
return $this->target;
}
/**
* {@inheritdoc}
*/
public function setKey(string $key): void {
if (!isset($this->key)) {
$this->key = $key;
}
}
/**
* {@inheritdoc}
*/
public function getKey(): ?string {
return $this->key;
}
/**
* {@inheritdoc}
*/
public function getConnectionOptions(): array {
return $this->connectionOptions;
}
/**
* {@inheritdoc}
*/
public function destroy(): void {}
/**
* Logs a query duration in the DB logger.
*
* @param string $query
* The query to be logged.
* @param array $args
* Arguments passed to the query.
* @param float $duration
* The duration of the query run.
*
* @throws \RuntimeException
* If an attempt to log was made but the logger is not started.
*/
protected function log(string $query, array $args, float $duration): void {
if (!$this->logger) {
throw new \RuntimeException('Cannot log query as the logger is not started.');
}
$statement = $this->getStatement()->setQuery($query);
$event = new StatementExecutionEndEvent(
spl_object_id($statement),
$this->getKey(),
$this->getTarget(),
$query,
$args,
$this->findCallerFromDebugBacktrace(),
microtime(TRUE)
);
$this->logger->logFromEvent($event);
}
/**
* Returns and statically caches a DB statement stub used to log a query.
*
* The Drupal core database logger cannot be swapped because, instead of being
* injected, is hardcoded in \Drupal\Core\Database\Database::startLog(). But
* the \Drupal\Core\Database\Log::log() is expecting a database statement of
* type \Drupal\Core\Database\StatementInterface as first argument and the
* SPARQL database driver uses no StatementInterface class. Workaround this
* limitation by faking a database statement object just to honour the logger
* requirement. We use a statement stub that only stores the connection and
* the query to be used when logging the event.
*
* @return \Drupal\sparql_entity_storage\Driver\Database\sparql\StatementStub
* A faked statement object.
*
* @see \Drupal\Core\Database\Database::startLog()
* @see \Drupal\Core\Database\Log
* @see \Drupal\Core\Database\StatementInterface
* @see \Drupal\sparql_entity_storage\Driver\Database\sparql\StatementStub
* @see \Drupal\sparql_entity_storage\Driver\Database\sparql\Connection::log()
*/
protected function getStatement(): StatementStub {
if (!isset($this->statementStub)) {
$this->statementStub = (new StatementStub())->setDatabaseConnection($this);
}
return $this->statementStub;
}
/**
* Creates an array of database connection options from a URL.
*
* @param string $url
* The URL.
* @param string $root
* The root directory of the Drupal installation. Some database drivers,
* like for example SQLite, need this information.
*
* @return array
* The connection options.
*
* @throws \InvalidArgumentException
* Exception thrown when the provided URL does not meet the minimum
* requirements.
*
* @see \Drupal\Core\Database\Database::convertDbUrlToConnectionInfo()
*
* @internal
* This method should not be called. Use
* \Drupal\Core\Database\Database::convertDbUrlToConnectionInfo() instead.
*/
public static function createConnectionOptionsFromUrl($url, $root) {
$url_components = parse_url($url);
if (!isset($url_components['scheme'], $url_components['host'])) {
throw new \InvalidArgumentException('Minimum requirement: driver://host');
}
$url_components += [
'user' => '',
'pass' => '',
];
// Use reflection to get the namespace of the class being called.
$reflector = new \ReflectionClass(get_called_class());
$database = [
'host' => $url_components['host'],
'namespace' => $reflector->getNamespaceName(),
'driver' => $url_components['scheme'],
];
if (isset($url_components['port'])) {
$database['port'] = $url_components['port'];
}
return $database;
}
/**
* Creates a URL from an array of database connection options.
*
* @param array $connection_options
* The array of connection options for a database connection.
*
* @return string
* The connection info as a URL.
*
* @throws \InvalidArgumentException
* Exception thrown when the provided array of connection options does not
* meet the minimum requirements.
*
* @see \Drupal\Core\Database\Database::getConnectionInfoAsUrl()
*
* @internal
* This method should not be called. Use
* \Drupal\Core\Database\Database::getConnectionInfoAsUrl() instead.
*/
public static function createUrlFromConnectionOptions(array $connection_options) {
if (!isset($connection_options['driver'], $connection_options['database'])) {
throw new \InvalidArgumentException("As a minimum, the connection options array must contain at least the 'driver' and 'database' keys");
}
$user = '';
if (isset($connection_options['username'])) {
$user = $connection_options['username'];
if (isset($connection_options['password'])) {
$user .= ':' . $connection_options['password'];
}
$user .= '@';
}
$host = empty($connection_options['host']) ? 'localhost' : $connection_options['host'];
$db_url = $connection_options['driver'] . '://' . $user . $host;
if (isset($connection_options['port'])) {
$db_url .= ':' . $connection_options['port'];
}
$db_url .= '/' . $connection_options['database'];
if (isset($connection_options['prefix']['default']) && $connection_options['prefix']['default'] !== '') {
$db_url .= '#' . $connection_options['prefix']['default'];
}
return $db_url;
}
/**
* Returns the status of a database API event toggle.
*
* @param string $eventName
* The name of the event to check.
*
* @return bool
* TRUE if the event is going to be fired by the database API, FALSE
* otherwise.
*/
public function isEventEnabled(string $eventName): bool {
return $this->enabledEvents[$eventName] ?? FALSE;
}
/**
* Enables database API events dispatching.
*
* @param string[] $eventNames
* A list of database events to be enabled.
*
* @return $this
*/
public function enableEvents(array $eventNames): ConnectionInterface {
foreach ($eventNames as $eventName) {
assert(class_exists($eventName), "Event class {$eventName} does not exist");
$this->enabledEvents[$eventName] = TRUE;
}
return $this;
}
/**
* Disables database API events dispatching.
*
* @param string[] $eventNames
* A list of database events to be disabled.
*
* @return $this
*/
public function disableEvents(array $eventNames): ConnectionInterface {
foreach ($eventNames as $eventName) {
assert(class_exists($eventName), "Event class {$eventName} does not exist");
$this->enabledEvents[$eventName] = FALSE;
}
return $this;
}
/**
* Determine the last non-database method that called the database API.
*
* Traversing the call stack from the very first call made during the
* request, we define "the routine that called this query" as the last entry
* in the call stack that is not any method called from the namespace of the
* database driver, is not inside the Drupal\Core\Database namespace and does
* have a file (which excludes call_user_func_array(), anonymous functions
* and similar). That makes the climbing logic very simple, and handles the
* variable stack depth caused by the query builders.
*
* See the @link http://php.net/debug_backtrace debug_backtrace() @endlink
* function.
*
* @return array
* This method returns a stack trace entry similar to that generated by
* debug_backtrace(). However, it flattens the trace entry and the trace
* entry before it so that we get the function and args of the function that
* called into the database system, not the function and args of the
* database call itself.
*/
public function findCallerFromDebugBacktrace(): array {
$stack = $this->removeDatabaseEntriesFromDebugBacktrace($this->getDebugBacktrace(), $this->getConnectionOptions()['namespace']);
// Return the first function call whose stack entry has a 'file' key, that
// is, it is not a callback or a closure.
for ($i = 0; $i < count($stack); $i++) {
if (!empty($stack[$i]['file'])) {
return [
'file' => $stack[$i]['file'],
'line' => $stack[$i]['line'],
'function' => $stack[$i + 1]['function'],
'class' => $stack[$i + 1]['class'] ?? NULL,
'type' => $stack[$i + 1]['type'] ?? NULL,
'args' => $stack[$i + 1]['args'] ?? [],
];
}
}
return [];
}
/**
* Removes database related calls from a backtrace array.
*
* @param array $backtrace
* A standard PHP backtrace. Passed by reference.
* @param string $driver_namespace
* The PHP namespace of the database driver.
*
* @return array
* The cleaned backtrace array.
*/
public static function removeDatabaseEntriesFromDebugBacktrace(array $backtrace, string $driver_namespace): array {
// Starting from the very first entry processed during the request, find
// the first function call that can be identified as a call to a
// method/function in the database layer.
for ($n = count($backtrace) - 1; $n >= 0; $n--) {
// If the call was made from a function, 'class' will be empty. We give
// it a default empty string value in that case.
$class = $backtrace[$n]['class'] ?? '';
if (str_starts_with($class, __NAMESPACE__) || str_starts_with($class, $driver_namespace)) {
break;
}
}
return array_values(array_slice($backtrace, $n));
}
/**
* Gets the debug backtrace.
*
* Wraps the debug_backtrace function to allow mocking results in PHPUnit
* tests.
*
* @return array[]
* The debug backtrace.
*/
protected function getDebugBacktrace(): array {
return debug_backtrace();
}
}
/**
* @} End of "addtogroup database".
*/
