Skip to content

Commit

Permalink
Prepared statements added
Browse files Browse the repository at this point in the history
  • Loading branch information
zozlak committed May 7, 2021
1 parent 86d0889 commit 1b5fe58
Show file tree
Hide file tree
Showing 8 changed files with 285 additions and 5 deletions.
25 changes: 23 additions & 2 deletions README.md
Expand Up @@ -40,6 +40,29 @@ foreach ($results as $i) {
}
```

### Parameterized queries

The `StandardConnection` class provides a [PDO](https://www.php.net/manual/en/book.pdo.php)-like API for parameterized queries (aka prepared statements).
Parameterized queries:

* Are the right way to assure all named nodes/blank nodes/literals/quads/etc. in the SPARQL query are properly escaped.
* Protect against [SPARQL injections](https://www.google.com/search?q=sparql+injection).
* Don't provide any speedup (in contrary to SQL parameterized queries).

```php
include 'vendor/autoload.php';
$factory = new \quickRdf\DataFactory();
$connection = new \sparqlClient\StandardConnection('https://query.wikidata.org/sparql', $factory);
$query = $connection->prepare('SELECT * WHERE {?a ? ?c . ?a :sf ?d .} LIMIT 10');
$query->execute([
$factory->namedNode('http://creativecommons.org/ns#license'),
'sf' => $factory->namedNode('http://schema.org/softwareVersion'),
]);
foreach ($query as $i) {
print_r($i);
}
```

### Advanced usage

* You may also provide any PSR-18 HTTP client and/or PSR-17 HTTP request factory to the `\sparqlClient\StandardConnection` constructor.
Expand All @@ -56,7 +79,5 @@ foreach ($results as $i) {

## FAQ

* **What about parameterized queries?**\
They'll be added in the future.
* **What about integration of INSERT/UPDATE/DELETE queries with the \rdfInterface\Dataset or \rdfInterface\QuadIterator?**\
Will be added in the future.
3 changes: 2 additions & 1 deletion composer.json
Expand Up @@ -18,7 +18,8 @@
"psr/http-factory": "^1.0",
"psr/http-client": "^1.0",
"halaxa/json-machine": "^0.6.0",
"guzzlehttp/guzzle": "^7.2"
"guzzlehttp/guzzle": "^7.2",
"sweetrdf/rdf-helpers": "^0.7.0"
},
"suggest": {
"http-interop/http-factory-guzzle": "^1.0",
Expand Down
147 changes: 147 additions & 0 deletions src/sparqlClient/PreparedStatement.php
@@ -0,0 +1,147 @@
<?php

/*
* To change this license header, choose License Headers in Project Properties.
* To change this template file, choose Tools | Templates
* and open the template in the editor.
*/

namespace sparqlClient;

use PDO;
use rdfInterface\Term as iTerm;
use rdfInterface\Quad as iQuad;
use rdfHelpers\NtriplesUtil;

/**
* Description of PreparedStatement
*
* @author zozlak
*/
class PreparedStatement implements StatementInterface {

const PH_POSIT = '(?>\\G|[^\\\\])([?])(?>$|[^a-zA-Z0-9_\x{00C0}-\x{00D6}\x{00D8}-\x{00F6}\x{00F8}-\x{02FF}\x{0370}-\x{037D}\x{037F}-\x{1FFF}\x{200C}-\x{200D}\x{2070}-\x{218F}\x{2C00}-\x{2FEF}\x{3001}-\x{D7FF}\x{F900}-\x{FDCF}\x{FDF0}-\x{FFFD}\x{10000}-\x{EFFFF}])';
const PH_NAMED = '(?>\\G|[^\\\\]):([a-zA-Z0-9]+)';

private Statement $statement;

/**
*
* @var array<iTerm>
*/
private array $param = [];

public function __construct(private string $query,
private SimpleConnectionInterface $connection,
private bool $ask = false) {
$this->parseQuery();
}

public function bindParam(int | string $parameter, iTerm &$variable): bool {
if (!array_key_exists($parameter, $this->param)) {
throw new SparqlException("Unknown parameter $parameter");
}
$this->param[$parameter] = $variable;
return true;
}

public function bindValue(int | string $parameter, iTerm $value): bool {
if (!array_key_exists($parameter, $this->param)) {
throw new SparqlException("Unknown parameter $parameter");
}
$this->param[$parameter] = $value;
return true;
}

public function execute(array $parameters = []): bool {
foreach ($parameters as $n => $v) {
$this->bindValue($n, $v);
}
$query = $this->getQuery();
if ($this->ask) {
$this->statement = $this->connection->askQuery($query);
} else {
$this->statement = $this->connection->query($query);
}
return true;
}

public function fetch(int $fetchStyle = PDO::FETCH_OBJ): object | array | false {
return $this->statement->fetch($fetchStyle);
}

public function fetchAll(int $fetchStyle = PDO::FETCH_OBJ): array {
return $this->statement->fetchAll($fetchStyle);
}

public function fetchColumn(): object | false {
return $this->statement->fetchColumn();
}

public function current(): mixed {
return $this->statement->current();
}

public function key(): \scalar {
return $this->statement->key();
}

public function next(): void {
$this->statement->next();
}

public function rewind(): void {
$this->statement->rewind();
}

public function valid(): bool {
return $this->statement->valid();
}

private function parseQuery(): void {
$matches = null;
preg_match_all($this->getPlaceholderRegex(), $this->query, $matches);
$n = 0;
foreach ($matches[1] ?? [] as $i) {
if (!empty($i)) {
$this->param[$i] = null;
} else {
$this->param[$n] = null;
$n++;
}
}
}

private function getQuery(): string {
$param = $this->param;
$query = preg_replace_callback($this->getPlaceholderRegex(), function (array $matches) use ($param) {
static $n = 0;
if (isset($matches[2])) {
$pn = $n;
$from = '?';
$to = $this->param[$pn] ?? null;
$n++;
} else {
$pn = $matches[1];
$from = ":$pn";
$to = $this->escapeValue($pn);
}
if ($to === null) {
throw new SparqlException("Parameter $pn value missing");
}
return str_replace($from, $to, $matches[0]);
}, $this->query);
return $query;
}

private function getPlaceholderRegex(): string {
return '/' . self::PH_NAMED . '|' . self::PH_POSIT . '/u';
}

private function escapeValue(string $paramName): string | null {
if (!isset($this->param[$paramName])) {
return null;
}
return NtriplesUtil::serialize($this->param[$paramName]);
}
}
24 changes: 24 additions & 0 deletions src/sparqlClient/SimpleConnectionInterface.php
@@ -0,0 +1,24 @@
<?php

/*
* To change this license header, choose License Headers in Project Properties.
* To change this template file, choose Tools | Templates
* and open the template in the editor.
*/

namespace sparqlClient;

/**
*
* @author zozlak
*/
interface SimpleConnectionInterface {

public function query(string $query): StatementInterface;

public function askQuery(string $query): bool;

public function prepare(string $query): StatementInterface;

public function prepareAsk(string $query): StatementInterface;
}
10 changes: 9 additions & 1 deletion src/sparqlClient/StandardConnection.php
Expand Up @@ -21,7 +21,7 @@
*
* @author zozlak
*/
class StandardConnection {
class StandardConnection implements SimpleConnectionInterface {

private string $url;
private Connection $connection;
Expand All @@ -45,6 +45,14 @@ public function askQuery(string $query): bool {
return $this->connection->askQuery($this->getRequest($query));
}

public function prepare(string $query): PreparedStatement {
return new PreparedStatement($query, $this, false);
}

public function prepareAsk(string $query): PreparedStatement {
return new PreparedStatement($query, $this, true);
}

private function getRequest(string $query): RequestInterface {
return $this->requestFactory->createRequest('POST', $this->url)->
withBody(Utils::streamFor(http_build_query(['query' => $query])))->
Expand Down
14 changes: 13 additions & 1 deletion src/sparqlClient/Statement.php
Expand Up @@ -23,7 +23,7 @@
*
* @author zozlak
*/
class Statement implements \Iterator {
class Statement implements StatementInterface {

const LANG_PROP = 'xml:lang';

Expand Down Expand Up @@ -129,4 +129,16 @@ public function rewind(): void {
public function valid(): bool {
return $this->currentRow !== null;
}

public function bindParam(int | string $parameter, iTerm &$variable): bool {
return false;
}

public function bindValue(int | string $parameter, iTerm $value): bool {
return false;
}

public function execute(array $parameters = []): bool {
return false;
}
}
30 changes: 30 additions & 0 deletions src/sparqlClient/StatementInterface.php
@@ -0,0 +1,30 @@
<?php

/*
* To change this license header, choose License Headers in Project Properties.
* To change this template file, choose Tools | Templates
* and open the template in the editor.
*/

namespace sparqlClient;

use rdfInterface\Term as iTerm;

/**
*
* @author zozlak
*/
interface StatementInterface extends \Iterator {

public function bindParam(int | string $parameter, iTerm &$variable): bool;

public function bindValue(int | string $parameter, iTerm $value): bool;

public function execute(array $parameters = []): bool;

public function fetchAll(int $fetchStyle = PDO::FETCH_OBJ): array;

public function fetch(int $fetchStyle = PDO::FETCH_OBJ): object | array | false;

public function fetchColumn(): object | false;
}
37 changes: 37 additions & 0 deletions tests/SparqlTest.php
Expand Up @@ -70,6 +70,19 @@ public function testAsk(): void {
$this->assertFalse($c->askQuery($query));
}

public function testPrepare(): void {
$df = new DataFactory();
$c = new StandardConnection('https://query.wikidata.org/sparql', $df);
$q = $c->prepare('SELECT * WHERE {?a ? ?c . ?a :sf ?d . ?a ? ?e} LIMIT 1');
$q->execute([
$df->namedNode('http://creativecommons.org/ns#license'),
$df->namedNode('http://schema.org/dateModified'),
'sf' => $df->namedNode('http://schema.org/softwareVersion'),
]);
$r = $q->fetchAll();
$this->assertCount(1, $r);
}

public function testExceptions(): void {
$df = new DataFactory();
$c = new StandardConnection('https://query.wikidata.org/sparql', $df);
Expand All @@ -95,5 +108,29 @@ public function testExceptions(): void {
} catch (SparqlException $ex) {
$this->assertStringStartsWith('Query execution failed with HTTP', $ex->getMessage());
}

$query = "";
try {
$q = $c->prepare($query);
$q->execute([$df->literal('foo')]);
} catch (SparqlException $ex) {
$this->assertEquals('Unknown parameter 0', $ex->getMessage());
}

$query = "?";
try {
$q = $c->prepare($query);
$q->execute();
} catch (SparqlException $ex) {
$this->assertEquals('Parameter 0 value missing', $ex->getMessage());
}

$query = "? :p ";
try {
$q = $c->prepare($query);
$q->execute([$df->literal('foo')]);
} catch (SparqlException $ex) {
$this->assertEquals('Parameter p value missing', $ex->getMessage());
}
}
}

0 comments on commit 1b5fe58

Please sign in to comment.