Passed
Push — master ( c327c2...0adcf0 )
by Ondřej
02:45
created

StatementExecution::processResult()   C

Complexity

Conditions 9
Paths 9

Size

Total Lines 23
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 18
nc 9
nop 3
dl 0
loc 23
rs 5.8541
c 0
b 0
f 0
1
<?php
2
declare(strict_types=1);
3
namespace Ivory\Connection;
4
5
use Ivory\Exception\InvalidStateException;
6
use Ivory\Exception\ResultDimensionException;
7
use Ivory\Exception\StatementException;
8
use Ivory\Exception\ConnectionException;
9
use Ivory\Exception\StatementExceptionFactory;
10
use Ivory\Exception\UsageException;
11
use Ivory\Ivory;
12
use Ivory\Query\ICommand;
13
use Ivory\Query\IRelationDefinition;
14
use Ivory\Query\ISqlPatternStatement;
15
use Ivory\Query\SqlCommand;
16
use Ivory\Query\SqlRelationDefinition;
17
use Ivory\Relation\IColumn;
18
use Ivory\Relation\ITuple;
19
use Ivory\Result\CommandResult;
20
use Ivory\Result\CopyInResult;
21
use Ivory\Result\CopyOutResult;
22
use Ivory\Result\ICommandResult;
23
use Ivory\Result\IQueryResult;
24
use Ivory\Result\IResult;
25
use Ivory\Result\QueryResult;
26
27
class StatementExecution implements IStatementExecution
28
{
29
    private $connCtl;
30
    private $typeCtl;
31
    private $stmtExFactory;
32
33
    public function __construct(ConnectionControl $connCtl, ITypeControl $typeCtl)
34
    {
35
        $this->connCtl = $connCtl;
36
        $this->typeCtl = $typeCtl;
37
        $this->stmtExFactory = Ivory::getCoreFactory()->createStatementExceptionFactory($this);
38
    }
39
40
    public function query($sqlFragmentPatternOrRelationDefinition, ...$fragmentsAndParams): IQueryResult
41
    {
42
        $typeDict = $this->typeCtl->getTypeDictionary();
43
44
        try {
45
            if ($sqlFragmentPatternOrRelationDefinition instanceof ISqlPatternStatement) {
46
                $this->checkMaxArgs($fragmentsAndParams, 1);
47
                $sqlRelDef = $sqlFragmentPatternOrRelationDefinition;
48
                $serializeArgs = $fragmentsAndParams;
49
                $sql = $sqlRelDef->toSql($typeDict, ...$serializeArgs);
0 ignored issues
show
Bug introduced by
$serializeArgs is expanded, but the parameter $namedParameterValues of Ivory\Query\ISqlPatternStatement::toSql() does not expect variable arguments. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

49
                $sql = $sqlRelDef->toSql($typeDict, /** @scrutinizer ignore-type */ ...$serializeArgs);
Loading history...
50
            } elseif ($sqlFragmentPatternOrRelationDefinition instanceof IRelationDefinition) {
51
                $this->checkMaxArgs($fragmentsAndParams, 0);
52
                $relDef = $sqlFragmentPatternOrRelationDefinition;
53
                $sql = $relDef->toSql($typeDict);
54
            } else {
55
                $relDef = SqlRelationDefinition::fromFragments(
56
                    $sqlFragmentPatternOrRelationDefinition,
57
                    ...$fragmentsAndParams
58
                );
59
                $sql = $relDef->toSql($typeDict);
60
            }
61
        } catch (InvalidStateException $e) {
62
            throw new \InvalidArgumentException($e->getMessage());
63
        }
64
65
        return $this->rawQuery($sql);
66
    }
67
68
    public function querySingleTuple($sqlFragmentPatternOrRelationDefinition, ...$fragmentsAndParams): ITuple
69
    {
70
        $rel = $this->query($sqlFragmentPatternOrRelationDefinition, ...$fragmentsAndParams);
71
        if ($rel->count() != 1) {
72
            throw new ResultDimensionException(
73
                "The query should have resulted in exactly one row, but has {$rel->count()} rows."
74
            );
75
        }
76
        return $rel->tuple();
77
    }
78
79
    public function querySingleColumn($sqlFragmentPatternOrRelationDefinition, ...$fragmentsAndParams): IColumn
80
    {
81
        $rel = $this->query($sqlFragmentPatternOrRelationDefinition, ...$fragmentsAndParams);
82
83
        $colCnt = count($rel->getColumns());
84
        if ($colCnt != 1) {
85
            throw new ResultDimensionException(
86
                "The query should have resulted in exactly one column, but has $colCnt columns."
87
            );
88
        }
89
90
        return $rel->col(0);
91
    }
92
93
    public function querySingleValue($sqlFragmentPatternOrRelationDefinition, ...$fragmentsAndParams)
94
    {
95
        $rel = $this->query($sqlFragmentPatternOrRelationDefinition, ...$fragmentsAndParams);
96
97
        $rowCnt = $rel->count();
98
        if ($rowCnt != 1) {
99
            throw new ResultDimensionException(
100
                "The query should have resulted in exactly one row, but has $rowCnt rows."
101
            );
102
        }
103
104
        $colCnt = count($rel->getColumns());
105
        if ($colCnt != 1) {
106
            throw new ResultDimensionException(
107
                "The query should have resulted in exactly one column, but has $colCnt columns."
108
            );
109
        }
110
111
        return $rel->value();
112
    }
113
114
    public function command($sqlFragmentPatternOrCommand, ...$fragmentsAndParams): ICommandResult
115
    {
116
        $typeDict = $this->typeCtl->getTypeDictionary();
117
118
        try {
119
            if ($sqlFragmentPatternOrCommand instanceof ISqlPatternStatement) {
120
                $this->checkMaxArgs($fragmentsAndParams, 1);
121
                $sqlCommand = $sqlFragmentPatternOrCommand;
122
                $serializeArgs = $fragmentsAndParams;
123
                $sql = $sqlCommand->toSql($typeDict, ...$serializeArgs);
0 ignored issues
show
Bug introduced by
$serializeArgs is expanded, but the parameter $namedParameterValues of Ivory\Query\ISqlPatternStatement::toSql() does not expect variable arguments. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

123
                $sql = $sqlCommand->toSql($typeDict, /** @scrutinizer ignore-type */ ...$serializeArgs);
Loading history...
124
            } elseif ($sqlFragmentPatternOrCommand instanceof ICommand) {
125
                $this->checkMaxArgs($fragmentsAndParams, 0);
126
                $command = $sqlFragmentPatternOrCommand;
127
                $sql = $command->toSql($typeDict);
128
            } else {
129
                $command = SqlCommand::fromFragments($sqlFragmentPatternOrCommand, ...$fragmentsAndParams);
130
                $sql = $command->toSql($typeDict);
131
            }
132
        } catch (InvalidStateException $e) {
133
            throw new \InvalidArgumentException($e->getMessage());
134
        }
135
136
        return $this->rawCommand($sql);
137
    }
138
139
    private function checkMaxArgs($args, int $maxCount): void
140
    {
141
        if (count($args) > $maxCount) {
142
            throw new \InvalidArgumentException('Too many arguments given.');
143
        }
144
    }
145
146
    public function rawQuery(string $sqlQuery): IQueryResult
147
    {
148
        $result = $this->executeRawStatement($sqlQuery);
149
        if ($result instanceof IQueryResult) {
150
            return $result;
151
        } else {
152
            throw new UsageException(
153
                'The supplied SQL statement was supposed to be a query, but it did not return a result set. ' .
154
                'Did you mean to call command() or rawCommand()?'
155
            );
156
        }
157
    }
158
159
    public function rawCommand(string $sqlCommand): ICommandResult
160
    {
161
        $result = $this->executeRawStatement($sqlCommand);
162
        if ($result instanceof ICommandResult) {
163
            return $result;
164
        } else {
165
            throw new UsageException(
166
                'The supplied SQL statement was supposed to be a command, but it returned a result set. ' .
167
                'Did you mean to call query() or rawQuery()?'
168
            );
169
        }
170
    }
171
172
    public function executeStatement($sqlStatement): IResult
173
    {
174
        if (is_string($sqlStatement)) {
175
            $rawStatement = $sqlStatement;
176
        } elseif ($sqlStatement instanceof ISqlPatternStatement) {
177
            $typeDict = $this->typeCtl->getTypeDictionary();
178
            $rawStatement = $sqlStatement->toSql($typeDict);
179
        } else {
180
            throw new \InvalidArgumentException(
181
                '$sqlStatement must either by a string or ' . ISqlPatternStatement::class
182
            );
183
        }
184
185
        return $this->executeRawStatement($rawStatement);
186
    }
187
188
    private function executeRawStatement(string $sqlStatement): IResult
189
    {
190
        $connHandler = $this->connCtl->requireConnection();
191
192
        while (pg_connection_busy($connHandler)) { // just to make things safe, it shall not ever happen
193
            usleep(1);
194
        }
195
196
        // pg_send_query_params(), as opposed to pg_send_query(), prevents $stmt from containing multiple statements
197
        $sent = pg_send_query_params($connHandler, $sqlStatement, []);
198
        if (!$sent) {
199
            // TODO: consider trapping errors to get more detailed error message
200
            throw new ConnectionException('Error sending the query to the database.');
201
        }
202
203
        $resultHandler = pg_get_result($connHandler);
204
        if ($resultHandler === false) {
0 ignored issues
show
introduced by
The condition $resultHandler === false can never be true.
Loading history...
205
            throw new ConnectionException('No results received from the database.');
206
        }
207
        /**
208
         * For erroneous queries, one must call pg_get_result() once again to update the structures at the client side.
209
         * Even worse, a loop might actually be needed according to
210
         * {@link http://www.postgresql.org/message-id/flat/[email protected]#[email protected]},
211
         * which does not sound logical, though, and hopefully was just meant as a generic way to processing results of
212
         * multiple statements sent in a single pg_send_query() call. Anyway, looping seems to be the safest solution.
213
         */
214
        while (pg_get_result($connHandler) !== false) {
215
            trigger_error('The database gave an unexpected result set.', E_USER_NOTICE);
216
        }
217
218
        return $this->processResult($connHandler, $resultHandler, $sqlStatement);
219
    }
220
221
    public function runScript(string $sqlScript): array
222
    {
223
        $connHandler = $this->connCtl->requireConnection();
224
225
        while (pg_connection_busy($connHandler)) { // just to make things safe, it shall not ever happen
226
            usleep(1);
227
        }
228
229
        $sent = pg_send_query($connHandler, $sqlScript);
230
        if (!$sent) {
231
            throw new ConnectionException('Error sending the query to the database.');
232
        }
233
234
        $resHandlers = [];
235
        while (($res = pg_get_result($connHandler)) !== false) {
236
            /* NOTE: Cannot process the result right away - the remaining results must all be read, or they would, in
237
             * case of error, block the connection from accepting further queries.
238
             */
239
            $resHandlers[] = $res;
240
        }
241
        $results = [];
242
        foreach ($resHandlers as $resHandler) {
243
            $results[] = $this->processResult($connHandler, $resHandler, $sqlScript);
244
        }
245
        return $results;
246
    }
247
248
    /**
249
     * @param resource $connHandler
250
     * @param resource $resHandler
251
     * @param string $query
252
     * @return IResult
253
     * @throws StatementException upon an SQL statement error
254
     */
255
    private function processResult($connHandler, $resHandler, string $query): IResult
256
    {
257
        $notice = $this->getLastResultNotice();
258
        $stat = pg_result_status($resHandler);
259
        switch ($stat) {
260
            case PGSQL_COMMAND_OK:
261
                return new CommandResult($resHandler, $notice);
262
            case PGSQL_TUPLES_OK:
263
                return new QueryResult($resHandler, $this->typeCtl, $notice);
264
            case PGSQL_COPY_IN:
265
                return new CopyInResult($connHandler, $resHandler, $notice);
266
            case PGSQL_COPY_OUT:
267
                return new CopyOutResult($connHandler, $resHandler, $notice);
268
269
            case PGSQL_EMPTY_QUERY:
270
            case PGSQL_BAD_RESPONSE:
271
            case PGSQL_NONFATAL_ERROR:
272
                // non-fatal errors are supposedly not possible to be received by the PHP client library, but anyway...
273
            case PGSQL_FATAL_ERROR:
274
                throw $this->stmtExFactory->createException($resHandler, $query, Ivory::getStatementExceptionFactory());
275
276
            default:
277
                throw new \UnexpectedValueException("Unexpected PostgreSQL statement result status: $stat", $stat);
278
        }
279
    }
280
281
    private function getLastResultNotice(): ?string
282
    {
283
        $resNotice = pg_last_notice($this->connCtl->requireConnection());
284
        $connNotice = $this->connCtl->getLastNotice();
285
        if ($resNotice !== $connNotice) {
286
            $this->connCtl->setLastNotice($resNotice);
287
            return $resNotice;
288
        } else {
289
            return null;
290
        }
291
    }
292
293
    public function getStatementExceptionFactory(): StatementExceptionFactory
294
    {
295
        return $this->stmtExFactory;
296
    }
297
}
298