Skip to content

Commit a2fcb8e

Browse files
JanJakesadamziel
andauthored
PDO API foundations (#291)
This PR implements foundational PDO APIs and uses them in the existing `WP_SQLite_Driver` class. The changes are best understood commit-by-commit, and they include the following: 1. Make `WP_PDO_MySQL_On_SQLite` extend `PDO`. 2. Implement `PDO::exec()` API. 3. Implement `PDO::query()` API. 4. Implement `PDOStatement` API for in-memory data (operating on PHP arrays) as `WP_PDO_Synthetic_Statement`. This is required for `PDO::query()` that must return a `PDOStatement` instance. 5. Implement `PDOStatement::fetch()` and `PDOStatement::fetchAll()` with the most common PDO fetch modes. 6. Implement `PDO` and `PDOStatement` `getAttribute()` and `setAttribute()` methods. 7. Implement `PDOStatement::setFetchMode()`. 8. Fix some PDO compatibility issues across all supported PHP versions. #### Synthetic vs. proxy PDO statement This initial work implements only a `WP_PDO_Synthetic_Statement` that requires the full PDO statement result set to be loaded in memory. This makes it easier to implement a gradual transition to the PDO API (the current driver loads all result sets in memory as well) and it can support all statement types, including those that are composed on the PHP side. In a follow-up work, it should be possible to transition most statement types to a proxy-like PDO statement that would internally use PDO SQLite statements directly, without eagerly fetching all data. The proxy will be required to address incompatibilities between SQLite and MySQL statements. #### Next steps Subsequent PRs will focus on the following: - Implement more of the `PDO` and `PDOStatement` APIs. - Implement a `WP_PDO_Proxy_Statement` as described above. - Simplify the SQLite driver state to only store a "last result statement" instead of the current list of properties. - Use the PDO API directly and remove the temporary proxy to the "old" driver API. --------- Co-authored-by: Adam Zieliński <adam@adamziel.com>
1 parent 9334343 commit a2fcb8e

8 files changed

+1390
-278
lines changed

tests/WP_PDO_MySQL_On_SQLite_PDO_API_Tests.php

Lines changed: 386 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,234 @@ class WP_PDO_MySQL_On_SQLite_PDO_API_Tests extends TestCase {
77
private $driver;
88

99
public function setUp(): void {
10-
$connection = new WP_SQLite_Connection( array( 'path' => ':memory:' ) );
11-
$this->driver = new WP_PDO_MySQL_On_SQLite( $connection, 'wp' );
10+
$this->driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;' );
11+
12+
// Run all tests with stringified fetch mode results, so we can use
13+
// assertions that are consistent across all tested PHP versions.
14+
// The "PDO::ATTR_STRINGIFY_FETCHES" mode is tested separately.
15+
$this->driver->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true );
16+
}
17+
18+
public function test_connection(): void {
19+
$driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=WordPress;' );
20+
$this->assertInstanceOf( PDO::class, $driver );
21+
}
22+
23+
public function test_dsn_parsing(): void {
24+
// Standard DSN.
25+
$driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp' );
26+
$this->assertSame( 'wp', $driver->query( 'SELECT DATABASE()' )->fetch()[0] );
27+
28+
// DSN with trailing semicolon.
29+
$driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;' );
30+
$this->assertSame( 'wp', $driver->query( 'SELECT DATABASE()' )->fetch()[0] );
31+
32+
// DSN with whitespace before argument names.
33+
$driver = new WP_PDO_MySQL_On_SQLite( "mysql-on-sqlite: path=:memory:; \n\r\t\v\fdbname=wp" );
34+
$this->assertSame( 'wp', $driver->query( 'SELECT DATABASE()' )->fetch()[0] );
35+
36+
// DSN with whitespace in the database name.
37+
$driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname= w p ' );
38+
$this->assertSame( ' w p ', $driver->query( 'SELECT DATABASE()' )->fetch()[0] );
39+
40+
// DSN with semicolon in the database name.
41+
$driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;dbname=w;;p;' );
42+
$this->assertSame( 'w;p', $driver->query( 'SELECT DATABASE()' )->fetch()[0] );
43+
44+
// DSN with semicolon in the database name and a terminating semicolon.
45+
$driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=w;;;p' );
46+
$this->assertSame( 'w;', $driver->query( 'SELECT DATABASE()' )->fetch()[0] );
47+
48+
// DSN with two semicolons in the database name.
49+
$driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=w;;;;p' );
50+
$this->assertSame( 'w;;p', $driver->query( 'SELECT DATABASE()' )->fetch()[0] );
51+
52+
// DSN with a "\0" byte (always terminates the DSN string).
53+
$driver = new WP_PDO_MySQL_On_SQLite( "mysql-on-sqlite:path=:memory:;dbname=w\0p;" );
54+
$this->assertSame( 'w', $driver->query( 'SELECT DATABASE()' )->fetch()[0] );
55+
}
56+
57+
public function test_query(): void {
58+
$result = $this->driver->query( "SELECT 1, 'abc'" );
59+
$this->assertInstanceOf( PDOStatement::class, $result );
60+
if ( PHP_VERSION_ID < 80000 ) {
61+
$this->assertSame(
62+
array(
63+
1 => '1',
64+
2 => '1',
65+
'abc' => 'abc',
66+
3 => 'abc',
67+
),
68+
$result->fetch()
69+
);
70+
} else {
71+
$this->assertSame(
72+
array(
73+
1 => '1',
74+
0 => '1',
75+
'abc' => 'abc',
76+
),
77+
$result->fetch()
78+
);
79+
}
80+
}
81+
82+
/**
83+
* @dataProvider data_pdo_fetch_methods
84+
*/
85+
public function test_query_with_fetch_mode( $query, $mode, $expected ): void {
86+
$stmt = $this->driver->query( $query, $mode );
87+
$result = $stmt->fetch();
88+
89+
if ( is_object( $expected ) ) {
90+
$this->assertInstanceOf( get_class( $expected ), $result );
91+
$this->assertSame( (array) $expected, (array) $result );
92+
} elseif ( PDO::FETCH_NAMED === $mode ) {
93+
// PDO::FETCH_NAMED returns all array keys as strings, even numeric
94+
// ones. This is not possible in plain PHP and might be a PDO bug.
95+
$this->assertSame( array_map( 'strval', array_keys( $expected ) ), array_keys( $result ) );
96+
$this->assertSame( array_values( $expected ), array_values( $result ) );
97+
} else {
98+
$this->assertSame( $expected, $result );
99+
}
100+
101+
$this->assertFalse( $stmt->fetch() );
102+
}
103+
104+
public function test_query_fetch_mode_not_set(): void {
105+
$result = $this->driver->query( 'SELECT 1' );
106+
if ( PHP_VERSION_ID < 80000 ) {
107+
$this->assertSame(
108+
array(
109+
1 => '1',
110+
2 => '1',
111+
),
112+
$result->fetch()
113+
);
114+
} else {
115+
$this->assertSame(
116+
array(
117+
1 => '1',
118+
0 => '1',
119+
),
120+
$result->fetch()
121+
);
122+
}
123+
$this->assertFalse( $result->fetch() );
124+
}
125+
126+
public function test_query_fetch_mode_invalid_arg_count(): void {
127+
$this->expectException( ArgumentCountError::class );
128+
$this->expectExceptionMessage( 'PDO::query() expects exactly 2 arguments for the fetch mode provided, 3 given' );
129+
$this->driver->query( 'SELECT 1', PDO::FETCH_ASSOC, 0 );
130+
}
131+
132+
public function test_query_fetch_default_mode_allow_any_args(): void {
133+
if ( PHP_VERSION_ID < 80100 ) {
134+
// On PHP < 8.1, fetch mode value of NULL is not allowed.
135+
$result = @$this->driver->query( 'SELECT 1', null, 1, 2, 'abc', array(), true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
136+
$this->assertFalse( $result );
137+
$this->assertSame( 'PDO::query(): SQLSTATE[HY000]: General error: mode must be an integer', error_get_last()['message'] );
138+
return;
139+
}
140+
141+
// On PHP >= 8.1, NULL fetch mode is allowed to use the default fetch mode.
142+
// In such cases, any additional arguments are ignored and not validated.
143+
$expected_result = array(
144+
array(
145+
1 => '1',
146+
0 => '1',
147+
),
148+
);
149+
150+
$result = $this->driver->query( 'SELECT 1' );
151+
$this->assertSame( $expected_result, $result->fetchAll() );
152+
153+
$result = $this->driver->query( 'SELECT 1', null );
154+
$this->assertSame( $expected_result, $result->fetchAll() );
155+
156+
$result = $this->driver->query( 'SELECT 1', null, 1 );
157+
$this->assertSame( $expected_result, $result->fetchAll() );
158+
159+
$result = $this->driver->query( 'SELECT 1', null, 'abc' );
160+
$this->assertSame( $expected_result, $result->fetchAll() );
161+
162+
$result = $this->driver->query( 'SELECT 1', null, 1, 2, 'abc', array(), true );
163+
$this->assertSame( $expected_result, $result->fetchAll() );
164+
}
165+
166+
public function test_query_fetch_class_not_enough_args(): void {
167+
$this->expectException( ArgumentCountError::class );
168+
$this->expectExceptionMessage( 'PDO::query() expects at least 3 arguments for the fetch mode provided, 2 given' );
169+
$this->driver->query( 'SELECT 1', PDO::FETCH_CLASS );
170+
}
171+
172+
public function test_query_fetch_class_too_many_args(): void {
173+
$this->expectException( ArgumentCountError::class );
174+
$this->expectExceptionMessage( 'PDO::query() expects at most 4 arguments for the fetch mode provided, 5 given' );
175+
$this->driver->query( 'SELECT 1', PDO::FETCH_CLASS, '\stdClass', array(), array() );
176+
}
177+
178+
public function test_query_fetch_class_invalid_class_type(): void {
179+
$this->expectException( TypeError::class );
180+
$this->expectExceptionMessage( 'PDO::query(): Argument #3 must be of type string, int given' );
181+
$this->driver->query( 'SELECT 1', PDO::FETCH_CLASS, 1 );
182+
}
183+
184+
public function test_query_fetch_class_invalid_class_name(): void {
185+
$this->expectException( TypeError::class );
186+
$this->expectExceptionMessage( 'PDO::query(): Argument #3 must be a valid class' );
187+
$this->driver->query( 'SELECT 1', PDO::FETCH_CLASS, 'non-existent-class' );
188+
}
189+
190+
public function test_query_fetch_class_invalid_constructor_args_type(): void {
191+
$this->expectException( TypeError::class );
192+
$this->expectExceptionMessage( 'PDO::query(): Argument #4 must be of type ?array, int given' );
193+
$this->driver->query( 'SELECT 1', PDO::FETCH_CLASS, 'stdClass', 1 );
194+
}
195+
196+
public function test_query_fetch_into_invalid_arg_count(): void {
197+
$this->expectException( ArgumentCountError::class );
198+
$this->expectExceptionMessage( 'PDO::query() expects exactly 3 arguments for the fetch mode provided, 2 given' );
199+
$this->driver->query( 'SELECT 1', PDO::FETCH_INTO );
200+
}
201+
202+
public function test_query_fetch_into_invalid_object_type(): void {
203+
$this->expectException( TypeError::class );
204+
$this->expectExceptionMessage( 'PDO::query(): Argument #3 must be of type object, int given' );
205+
$this->driver->query( 'SELECT 1', PDO::FETCH_INTO, 1 );
206+
}
207+
208+
public function test_exec(): void {
209+
$result = $this->driver->exec( 'SELECT 1' );
210+
$this->assertEquals( 0, $result );
211+
212+
$result = $this->driver->exec( 'CREATE TABLE t (id INT)' );
213+
$this->assertEquals( 0, $result );
214+
215+
$result = $this->driver->exec( 'INSERT INTO t (id) VALUES (1)' );
216+
$this->assertEquals( 1, $result );
217+
218+
$result = $this->driver->exec( 'INSERT INTO t (id) VALUES (2), (3)' );
219+
$this->assertEquals( 2, $result );
220+
221+
$result = $this->driver->exec( 'UPDATE t SET id = 10 + id WHERE id = 0' );
222+
$this->assertEquals( 0, $result );
223+
224+
$result = $this->driver->exec( 'UPDATE t SET id = 10 + id WHERE id = 1' );
225+
$this->assertEquals( 1, $result );
226+
227+
$result = $this->driver->exec( 'UPDATE t SET id = 10 + id WHERE id < 10' );
228+
$this->assertEquals( 2, $result );
229+
230+
$result = $this->driver->exec( 'DELETE FROM t WHERE id = 11' );
231+
$this->assertEquals( 1, $result );
232+
233+
$result = $this->driver->exec( 'DELETE FROM t' );
234+
$this->assertEquals( 2, $result );
235+
236+
$result = $this->driver->exec( 'DROP TABLE t' );
237+
$this->assertEquals( 0, $result );
12238
}
13239

14240
public function test_begin_transaction(): void {
@@ -50,4 +276,162 @@ public function test_rollback_no_active_transaction(): void {
50276
$this->expectExceptionCode( 0 );
51277
$this->driver->rollBack();
52278
}
279+
280+
public function test_fetch_default(): void {
281+
// Default fetch mode is PDO::FETCH_BOTH.
282+
$result = $this->driver->query( "SELECT 1, 'abc', 2" );
283+
if ( PHP_VERSION_ID < 80000 ) {
284+
$this->assertSame(
285+
array(
286+
1 => '1',
287+
2 => '2',
288+
'abc' => 'abc',
289+
3 => 'abc',
290+
4 => '2',
291+
),
292+
$result->fetch()
293+
);
294+
} else {
295+
$this->assertSame(
296+
array(
297+
1 => '1',
298+
0 => '1',
299+
'abc' => 'abc',
300+
'2' => '2',
301+
),
302+
$result->fetch()
303+
);
304+
}
305+
}
306+
307+
/**
308+
* @dataProvider data_pdo_fetch_methods
309+
*/
310+
public function test_fetch( $query, $mode, $expected ): void {
311+
$stmt = $this->driver->query( $query );
312+
$result = $stmt->fetch( $mode );
313+
314+
if ( is_object( $expected ) ) {
315+
$this->assertInstanceOf( get_class( $expected ), $result );
316+
$this->assertEquals( $expected, $result );
317+
} elseif ( PDO::FETCH_NAMED === $mode ) {
318+
// PDO::FETCH_NAMED returns all array keys as strings, even numeric
319+
// ones. This is not possible in plain PHP and might be a PDO bug.
320+
$this->assertSame( array_map( 'strval', array_keys( $expected ) ), array_keys( $result ) );
321+
$this->assertSame( array_values( $expected ), array_values( $result ) );
322+
} else {
323+
$this->assertSame( $expected, $result );
324+
}
325+
}
326+
327+
public function test_attr_default_fetch_mode(): void {
328+
$this->driver->setAttribute( PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_NUM );
329+
$result = $this->driver->query( "SELECT 'a', 'b', 'c'" );
330+
$this->assertSame(
331+
array( 'a', 'b', 'c' ),
332+
$result->fetch()
333+
);
334+
335+
$this->driver->setAttribute( PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC );
336+
$result = $this->driver->query( "SELECT 'a', 'b', 'c'" );
337+
$this->assertSame(
338+
array(
339+
'a' => 'a',
340+
'b' => 'b',
341+
'c' => 'c',
342+
),
343+
$result->fetch()
344+
);
345+
}
346+
347+
public function test_attr_stringify_fetches(): void {
348+
$this->driver->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true );
349+
$result = $this->driver->query( "SELECT 123, 1.23, 'abc', true, false" );
350+
$this->assertSame(
351+
array( '123', '1.23', 'abc', '1', '0' ),
352+
$result->fetch( PDO::FETCH_NUM )
353+
);
354+
355+
$this->driver->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, false );
356+
$result = $this->driver->query( "SELECT 123, 1.23, 'abc', true, false" );
357+
$this->assertSame(
358+
/*
359+
* On PHP < 8.1, "PDO::ATTR_STRINGIFY_FETCHES" set to "false" has no
360+
* effect when "PDO::ATTR_EMULATE_PREPARES" is "true" (the default).
361+
*
362+
* TODO: Consider supporting non-string values on PHP < 8.1 when both
363+
* "PDO::ATTR_STRINGIFY_FETCHES" and "PDO::ATTR_EMULATE_PREPARES"
364+
* are set to "false". This would require emulating the behavior,
365+
* as PDO SQLite on PHP < 8.1 seems to always return strings.
366+
*/
367+
PHP_VERSION_ID < 80100
368+
? array( '123', '1.23', 'abc', '1', '0' )
369+
: array( 123, 1.23, 'abc', 1, 0 ),
370+
$result->fetch( PDO::FETCH_NUM )
371+
);
372+
}
373+
374+
public function data_pdo_fetch_methods(): Generator {
375+
// PDO::FETCH_BOTH
376+
yield 'PDO::FETCH_BOTH' => array(
377+
"SELECT 1, 'abc', 2, 'two' as `2`",
378+
PDO::FETCH_BOTH,
379+
PHP_VERSION_ID < 80000
380+
? array(
381+
1 => '1',
382+
2 => 'two',
383+
'abc' => 'abc',
384+
3 => 'abc',
385+
4 => '2',
386+
5 => 'two',
387+
)
388+
: array(
389+
1 => '1',
390+
0 => '1',
391+
'abc' => 'abc',
392+
2 => 'two',
393+
3 => 'two',
394+
),
395+
);
396+
397+
// PDO::FETCH_NUM
398+
yield 'PDO::FETCH_NUM' => array(
399+
"SELECT 1, 'abc', 2, 'two' as `2`",
400+
PDO::FETCH_NUM,
401+
array( '1', 'abc', '2', 'two' ),
402+
);
403+
404+
// PDO::FETCH_ASSOC
405+
yield 'PDO::FETCH_ASSOC' => array(
406+
"SELECT 1, 'abc', 2, 'two' as `2`",
407+
PDO::FETCH_ASSOC,
408+
array(
409+
1 => '1',
410+
'abc' => 'abc',
411+
2 => 'two',
412+
),
413+
);
414+
415+
// PDO::FETCH_NAMED
416+
yield 'PDO::FETCH_NAMED' => array(
417+
"SELECT 1, 'abc', 2, 'two' as `2`",
418+
PDO::FETCH_NAMED,
419+
array(
420+
1 => '1',
421+
'abc' => 'abc',
422+
2 => array( '2', 'two' ),
423+
),
424+
);
425+
426+
// PDO::FETCH_OBJ
427+
yield 'PDO::FETCH_OBJ' => array(
428+
"SELECT 1, 'abc', 2, 'two' as `2`",
429+
PDO::FETCH_OBJ,
430+
(object) array(
431+
1 => '1',
432+
'abc' => 'abc',
433+
2 => 'two',
434+
),
435+
);
436+
}
53437
}

0 commit comments

Comments
 (0)