LGV_TZ_Lookup 1.4.2
Time zone lookup for PHP applications and HTTP clients
Loading...
Searching...
No Matches
databases.php
Go to the documentation of this file.
1<?php
2/***************************************************************************************************************************/
3/**
4 © Copyright 2023-2026, [Little Green Viper Software Development LLC](https://littlegreenviper.com)
5
6 LICENSE:
7
8 MIT License
9
10 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
11 files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy,
12 modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
13 Software is furnished to do so, subject to the following conditions:
14
15 The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
16
17 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
18 OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
20 CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
22 [Little Green Viper Software Development LLC](https://littlegreenviper.com)
23*/
24/***************************************************************************************************************************/
25/**
26 \file
27 \brief These are database integration tests for MySQL and PostgreSQL.
28
29 Run with --driver=mysql or --driver=pgsql. Connection overrides use LGV_TZ_TEST_HOST, PORT, USER, PASSWORD, and
30 ADMIN_DATABASE. The tests create their own uniquely named database and remove it in a finally block. They check
31 loading, binary encoding, lookup precedence, bounded row fetching, cursor cleanup, and transaction ownership.
32 --autoload=/path/to/vendor/autoload.php runs the same checks against a Composer consumer installation.
33*/
34
35declare(strict_types=1);
36
37$options = getopt('', ['driver:', 'autoload:']);
38$driver = $options['driver'] ?? 'mysql';
39if (!in_array($driver, ['mysql', 'pgsql'], true)) {
40 throw new InvalidArgumentException('--driver must be mysql or pgsql.');
41}
42if (isset($options['autoload'])) {
43 require $options['autoload'];
44} else {
45 require dirname(__DIR__).'/src/Sources/LGV_TZ_Lookup_Query.class.php';
46 require dirname(__DIR__).'/src/Sources/LGV_TZ_Lookup_Loader.class.php';
47}
49
50/***************************************************************************************************************************/
51/**
52 \brief This observes native PostgreSQL result sizes without keeping statements or cursors alive.
53 PHP heap counters alone do not include libpq's result buffers. A weak reference lets the test inspect the
54 active result and verify that it contains one row, while normal statement destruction still performs cleanup.
55*/
56class DatabaseTestStatement extends PDOStatement {
57 public static ?WeakReference $last = null; ///< The most recently prepared statement, while it remains alive.
58 protected function __construct() { self::$last = WeakReference::create($this); }
59}
60
61/***************************************************************************************************************************/
62/** \brief This checks a result and counts successful assertions. \throws RuntimeException if values differ. */
63function databaseCheck($expected, $actual, string $label): void {
64 global $checks;
65 ++$checks;
66 if ($expected !== $actual) {
67 throw new RuntimeException($label.': expected '.var_export($expected, true).', got '.var_export($actual, true));
68 }
69}
70
71/***************************************************************************************************************************/
72/**
73 \brief This exercises the installed database and lookup classes in an isolated database.
74 The function scope releases every test connection before the outer cleanup drops the PostgreSQL database.
75 \throws Exception if a database operation or assertion fails.
76*/
77function testDatabase(string $name, string $user, string $password, string $driver, string $host, int $port): void {
79 $database->reset_database();
80 $wrapper = $database->pdo_instance;
81 $property = new ReflectionProperty(LGV_TZ_Lookup_PDO::class, '_pdo');
82 if (PHP_VERSION_ID < 80100) { $property->setAccessible(true); }
83 $connection = $property->getValue($wrapper);
84 $connection->setAttribute(PDO::ATTR_STATEMENT_CLASS, [DatabaseTestStatement::class, []]);
85 databaseCheck('UTF8', $driver === 'pgsql' ? $connection->query('SHOW client_encoding')->fetchColumn() : 'UTF8', 'Connection encoding');
87 databaseCheck('', $lookup->get_tz(2, 2), 'Empty database');
88 $bounds = ['east' => 4, 'west' => 0, 'north' => 4, 'south' => 0];
89 $miss = [[0, 0], [4, 0], [4, 1], [0, 1], [0, 0]];
90 $hit = [[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]];
91 $database->store_entity(new LGV_TZ_Lookup_Entity('Region/First', $bounds, $miss));
92 databaseCheck('Region/First', $lookup->get_tz(2, 2), 'Existing single-candidate behavior');
93 $database->store_entity(new LGV_TZ_Lookup_Entity('Region/Second', $bounds, $miss));
94 $database->store_entity(new LGV_TZ_Lookup_Entity('Etc/GMT', $bounds, $hit));
95 databaseCheck('Etc/GMT', $lookup->get_tz(2, 2), 'Ocean fallback after named polygons miss');
96 $database->store_entity(new LGV_TZ_Lookup_Entity('Region/Third', $bounds, $hit));
97 $database->store_entity(new LGV_TZ_Lookup_Entity('Region/Fourth', $bounds, $hit));
98 databaseCheck('Region/Third', $lookup->get_tz(2, 2), 'Named precedence and primary-key order');
99 databaseCheck([], iterator_to_array($database->get_tz_polygons([])), 'Empty polygon iterator');
100 $decoded = $database->get_tz_entities([3]);
101 databaseCheck($hit, array_map(static fn($p) => array_map('intval', $p), $decoded[0]['polygon']), 'Legacy decoded-polygon API');
102
103 // A known circle crosses two decoder blocks. A second named candidate forces actual polygon decoding.
104 $ring = [];
105 for ($i = 0; $i < 2053; ++$i) {
106 $angle = 2 * M_PI * $i / 2053;
107 $ring[] = [40 + cos($angle), 40 + sin($angle)];
108 }
109 $ring[] = $ring[0];
110 $bounds = ['east' => 41, 'west' => 39, 'north' => 41, 'south' => 39];
111 $database->store_entity(new LGV_TZ_Lookup_Entity('Region/Circle', $bounds, $ring));
112 $database->store_entity(new LGV_TZ_Lookup_Entity('Region/CircleMiss', $bounds, [[39, 39], [41, 39], [41, 39.001], [39, 39.001], [39, 39]]));
113 foreach ([-0.99, -0.7, 0.0, 0.7, 0.99] as $x) {
114 foreach ([-0.99, -0.7, 0.0, 0.7, 0.99] as $y) {
115 databaseCheck($x * $x + $y * $y < 1 ? 'Region/Circle' : '', $lookup->get_tz(40 + $x, 40 + $y), 'Analytic circle '.$x.','.$y);
116 }
117 }
118 $sourceBytes = '';
119 foreach ($ring as $point) { $sourceBytes .= pack('d2', round($point[0], 6), round($point[1], 6)); }
120 $row = iterator_to_array($database->get_tz_polygons([6]))[0];
121 databaseCheck($sourceBytes, $row['polygon'], 'Packed double bytes survive database round trip');
122 unset($ring, $row, $sourceBytes);
123
124 // Many one-MiB blobs would exceed this bound if the PDO reader buffered the whole result in PHP memory.
125 $type = $driver === 'pgsql' ? 'BYTEA' : 'LONGBLOB';
126 $wrapper->preparedStatement('CREATE TEMPORARY TABLE binary_fixture (id INTEGER PRIMARY KEY, payload '.$type.')');
127 $blob = str_repeat(implode('', array_map('chr', range(0, 255))), 4096);
128 for ($i = 1; $i <= 24; ++$i) {
129 $wrapper->preparedStatement('INSERT INTO binary_fixture VALUES (?, ?)', [$i, $blob], false, [1 => PDO::PARAM_LOB]);
130 }
131 $wrapper->preparedStatement('INSERT INTO binary_fixture VALUES (?, ?)', [25, ''], false, [1 => PDO::PARAM_LOB]);
132 databaseCheck('', $wrapper->preparedStatement('SELECT payload FROM binary_fixture WHERE id = 25', [], true)[0]['payload'], 'Empty binary value');
133 if (function_exists('memory_reset_peak_usage')) { memory_reset_peak_usage(); }
134 $before = memory_get_usage();
135 $count = 0;
136 $nativePeak = 0;
137 foreach ($wrapper->preparedRows('SELECT payload FROM binary_fixture WHERE id <= ? ORDER BY id', [24], false) as $row) {
138 databaseCheck(hash('sha256', $blob), hash('sha256', $row['payload']), 'All 256 byte values in row '.++$count);
139 if ($driver === 'pgsql' && defined('Pdo\\Pgsql::ATTR_RESULT_MEMORY_SIZE')) {
140 $nativePeak = max($nativePeak, DatabaseTestStatement::$last->get()->getAttribute(\Pdo\Pgsql::ATTR_RESULT_MEMORY_SIZE));
141 }
142 unset($row);
143 }
144 databaseCheck(24, $count, 'All binary rows fetched');
145 if (function_exists('memory_reset_peak_usage')) {
146 $extra = memory_get_peak_usage() - $before;
147 databaseCheck(true, $extra < 8 * 1048576, 'Binary reader stays below eight MiB extra PHP memory');
148 printf(" Binary reader peak extra PHP memory: %.2f MiB for 24 MiB of rows.\n", $extra / 1048576);
149 }
150 if ($nativePeak > 0) {
151 databaseCheck(true, $nativePeak < 4 * 1048576, 'PostgreSQL native result buffer holds one row');
152 printf(" PostgreSQL native result buffer peak: %.2f MiB.\n", $nativePeak / 1048576);
153 }
154
155 $rows = $wrapper->preparedRows('SELECT payload FROM binary_fixture ORDER BY id', [], false);
156 foreach ($rows as $row) { break; }
157 unset($rows, $row);
158 databaseCheck(3, (int)$wrapper->preparedStatement('SELECT 3 AS value', [], true)[0]['value'], 'Connection usable after early cursor exit');
159 databaseCheck(false, $connection->inTransaction(), 'Reader leaves no transaction open');
160 $connection->beginTransaction();
161 $rows = $wrapper->preparedRows('SELECT payload FROM binary_fixture ORDER BY id', [], false);
162 foreach ($rows as $row) { break; }
163 unset($rows, $row);
164 databaseCheck(true, $connection->inTransaction(), 'Reader preserves caller transaction');
165 $wrapper->preparedStatement('INSERT INTO binary_fixture VALUES (?, ?)', [26, $blob], false, [1 => PDO::PARAM_LOB]);
166 databaseCheck(true, $connection->inTransaction(), 'Write preserves caller transaction');
167 $connection->rollBack();
168 databaseCheck(0, (int)$wrapper->preparedStatement('SELECT COUNT(*) AS count FROM binary_fixture WHERE id = 26', [], true)[0]['count'], 'Caller rollback undoes the write');
169 foreach ([true, false] as $write) {
170 try {
171 if ($write) { $wrapper->preparedStatement('INSERT INTO lgv_tz_missing_table VALUES (1)'); }
172 else { iterator_to_array($wrapper->preparedRows('SELECT * FROM lgv_tz_missing_table', [], false)); }
173 throw new RuntimeException('Missing table should fail.');
174 } catch (Exception $error) {
175 databaseCheck(true, $error->getPrevious() instanceof PDOException, 'Database exception retains PDO context');
176 }
177 databaseCheck(9, (int)$wrapper->preparedStatement('SELECT 9 AS value', [], true)[0]['value'], 'Connection usable after database error');
178 }
179
180 if ($driver === 'pgsql') {
181 // Exercise the server-cursor mechanism used by PHP 8.0-8.4, as well as the normal PHP 8.5 reader above.
182 $cursor = $connection->prepare('SELECT payload FROM binary_fixture WHERE id = ?', [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]);
183 $cursor->execute([1]);
184 $stream = $cursor->fetch(PDO::FETCH_ASSOC)['payload'];
185 databaseCheck($blob, stream_get_contents($stream), 'PostgreSQL server cursor preserves binary values');
186 fclose($stream);
187 $cursor->closeCursor();
188 unset($cursor);
189 databaseCheck(0, (int)$connection->query("SELECT COUNT(*) FROM pg_cursors WHERE name LIKE 'pdo_crsr_%'")->fetchColumn(), 'PostgreSQL server cursor released');
190 }
191 unset($blob);
192
193 // The real loader must rebuild the schema, restart IDs, and preserve MultiPolygon geometry.
194 $listener = new LGV_TZ_Lookup_Loader($database);
195 $stream = fopen('php://memory', 'w+b');
196 fwrite($stream, json_encode(['type' => 'FeatureCollection', 'features' => [[
197 'type' => 'Feature', 'properties' => ['tzid' => 'Etc/GMT'],
198 'geometry' => ['type' => 'MultiPolygon', 'coordinates' => [[ $hit ], [ [[10, 10], [14, 10], [14, 14], [10, 14], [10, 10]] ]]],
199 ]]], JSON_THROW_ON_ERROR));
200 rewind($stream);
201 try { (new JsonStreamingParser\Parser($stream, $listener))->parse(); }
202 finally { fclose($stream); }
203 databaseCheck(2, (int)$wrapper->preparedStatement('SELECT COUNT(*) AS count FROM timezones', [], true)[0]['count'], 'Loader resets schema and loads both polygons');
204 databaseCheck([1, 2], array_map(static fn($r) => (int)$r['id'], $wrapper->preparedStatement('SELECT id FROM timezones ORDER BY id', [], true)), 'Reset restarts generated IDs');
205 databaseCheck('Etc/GMT', $lookup->get_tz(2, 2), 'Loaded first polygon');
206 databaseCheck('Etc/GMT', $lookup->get_tz(12, 12), 'Loaded second polygon');
207}
208
209$host = getenv('LGV_TZ_TEST_HOST') ?: 'localhost';
210$port = (int)(getenv('LGV_TZ_TEST_PORT') ?: ($driver === 'pgsql' ? 5432 : 3306));
211$user = getenv('LGV_TZ_TEST_USER') ?: ($driver === 'pgsql' ? (getenv('USER') ?: get_current_user()) : 'root');
212$password = getenv('LGV_TZ_TEST_PASSWORD') ?: '';
213$dsn = $driver.':host='.$host.';port='.$port;
214if ($driver === 'pgsql') { $dsn .= ';dbname='.(getenv('LGV_TZ_TEST_ADMIN_DATABASE') ?: 'postgres'); }
215$admin = new PDO($dsn, $user, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
216$name = 'lgv_tz_test_'.bin2hex(random_bytes(12));
217$quote = $driver === 'pgsql' ? '"' : '`';
218$admin->exec('CREATE DATABASE '.$quote.$name.$quote);
219try {
221 echo 'Passed '.$checks.' '.$driver." integration checks.\n";
222} finally {
223 if (class_exists('LGV_TZ_Lookup_Loader', false)) { LGV_TZ_Lookup_Loader::$db_object = null; }
224 $admin->exec('DROP DATABASE '.$quote.$name.$quote);
225 echo "Removed temporary test database.\n";
226}
This observes native PostgreSQL result sizes without keeping statements or cursors alive....
Definition databases.php:56
static WeakReference $last
The most recently prepared statement, while it remains alive.
Definition databases.php:57
$options
Definition composer.php:6
$connection
Definition composer.php:31
foreach([[1, 'America/New_York', [0, 0, 4, 0, 4, 1, 0, 1, 0, 0]], [2, 'America/Chicago', [0, 0, 4, 0, 4, 4, 0, 4, 0, 0]], [3, 'Etc/GMT', [0, 0, 4, 0, 4, 4, 0, 4, 0, 0]],] as[$id, $zone, $points]) $wrapper
Definition composer.php:47
$database
Definition composer.php:52
if(!is_file($autoload)) $checks
Definition composer.php:11
$lookup
Definition composer.php:54
$quote
databaseCheck($expected, $actual, string $label)
This checks a result and counts successful assertions.
Definition databases.php:63
$driver
Definition databases.php:38
$user
$password
$dsn
$host
testDatabase(string $name, string $user, string $password, string $driver, string $host, int $port)
This exercises the installed database and lookup classes in an isolated database. The function scope ...
Definition databases.php:77
$port
$name
if($driver==='pgsql') $admin
$miss
$decoded
$rows
$hit
$error
Definition demo.php:116