diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8024657..a2eff6ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@
- ClickHouse: Fix offset (bug #1188)
- ClickHouse: Fix list of tables (bug #1176)
- Plugins: Methods showVariables() and showStatus() (bug #1157)
+- New plugin: IGDB driver
## Adminer 5.4.1 (released 2025-09-26)
- SQL command: Unlink NULL primary keys
diff --git a/adminer/file.inc.php b/adminer/file.inc.php
index e33abe9d..ca2aa27a 100644
--- a/adminer/file.inc.php
+++ b/adminer/file.inc.php
@@ -34,7 +34,8 @@ if ($_GET["file"] == "default.css") {
../externals/jush/modules/jush-sqlite.js;
../externals/jush/modules/jush-mssql.js;
../externals/jush/modules/jush-oracle.js;
-../externals/jush/modules/jush-simpledb.js', 'minify_js'));
+../externals/jush/modules/jush-simpledb.js;
+../externals/jush/modules/jush-igdb.js', 'minify_js'));
} elseif ($_GET["file"] == "logo.png") {
header("Content-Type: image/png");
echo compile_file('../adminer/static/logo.png');
diff --git a/externals/jush b/externals/jush
index 19fef253..d5b3666f 160000
--- a/externals/jush
+++ b/externals/jush
@@ -1 +1 @@
-Subproject commit 19fef25342407d2ad696f27ac7f195025c71b89b
+Subproject commit d5b3666fc80771fa97487ee959c93c86f1a2ad69
diff --git a/plugins/drivers/igdb.php b/plugins/drivers/igdb.php
new file mode 100644
index 00000000..965807cd
--- /dev/null
+++ b/plugins/drivers/igdb.php
@@ -0,0 +1,351 @@
+username = $username;
+ $this->password = $password;
+ return '';
+ }
+
+ function select_db($database) {
+ return ($database == "api");
+ }
+
+ function request($endpoint, $query) {
+ $context = stream_context_create(array('http' => array(
+ 'method' => 'POST',
+ 'header' => array(
+ "Content-Type: text/plain",
+ "Client-ID: $this->username",
+ "Authorization: Bearer $this->password",
+ ),
+ 'content' => $query,
+ 'ignore_errors' => true,
+ )));
+ $response = file_get_contents("https://api.igdb.com/v4/$endpoint", false, $context);
+ $return = json_decode($response, true);
+ if ($http_response_header[0] != 'HTTP/1.1 200 OK') {
+ if (is_array($return)) {
+ foreach (is_array($return[0]) ? $return : array($return) as $rows) {
+ foreach ($rows as $key => $val) {
+ $this->error .= '' . h($key) . ': ' . (is_url($val) ? '' . h($val) . '' : h($val)) . '
';
+ }
+ }
+ } else {
+ $this->error = h($response);
+ }
+ return false;
+ }
+ return $return;
+ }
+
+ function query($query, $unbuffered = false) {
+ if (preg_match('~^SELECT COUNT\(\*\) FROM (\w+)( WHERE ((MATCH \(search\) AGAINST \((.+)\))|.+))?$~', $query, $match)) {
+ return new Result(array($this->request("$match[1]/count", ($match[5] ? 'search "' . addcslashes($match[5], '\\"') . '";'
+ : ($match[3] ? 'where ' . str_replace(' AND ', ' & ', $match[3]) . ';'
+ : ''
+ )))));
+ }
+ if (preg_match('~^\s*endpoint\s+(\w+(/count)?)\s*;\s*(.*)$~s', $query, $match)) {
+ $endpoint = $match[1];
+ $response = $this->request($endpoint, $match[3]);
+ if ($response === false) {
+ return $response;
+ }
+ $return = new Result($match[2] ? array($response) : $response);
+ $return->table = $endpoint;
+ if ($endpoint == 'multiquery') {
+ $return->results = $response;
+ }
+ return $return;
+ }
+ $this->error = "Syntax:
DELIMITER ;;
endpoint <endpoint>; fields ...;";
+ return false;
+ }
+
+ function store_result() {
+ if ($this->multi && ($result = current($this->multi->results))) {
+ echo "
" . h($result['name']) . "
\n";
+ $this->multi->__construct($result['count'] ? array(array('count' => $result['count'])) : $result['result']);
+ }
+ return $this->multi;
+ }
+
+ function next_result(): bool {
+ return $this->multi && next($this->multi->results);
+ }
+
+ function quote($string): string {
+ return $string;
+ }
+ }
+
+ class Result {
+ public $num_rows;
+ public $table;
+ public $results = array();
+ private $result;
+ private $fields;
+
+ function __construct($result) {
+ $keys = array();
+ foreach ($result as $i => $row) {
+ foreach ($row as $key => $val) {
+ $keys[$key] = null;
+ if (is_array($val) && is_int($val[0])) {
+ $result[$i][$key] = "(" . implode(",", $val) . ")";
+ }
+ }
+ }
+ foreach ($result as $i => $row) {
+ $result[$i] = array_merge($keys, $row);
+ }
+ $this->result = $result;
+ $this->num_rows = count($result);
+ $this->fields = array_keys(idx($result, 0, array()));
+ }
+
+ function fetch_assoc() {
+ $row = current($this->result);
+ next($this->result);
+ return $row;
+ }
+
+ function fetch_row() {
+ $row = $this->fetch_assoc();
+ return ($row ? array_values($row) : false);
+ }
+
+ function fetch_field(): \stdClass {
+ $field = current($this->fields);
+ next($this->fields);
+ return ($field != '' ? (object) array('name' => $field, 'type' => 15, 'charsetnr' => 0, 'orgtable' => $this->table) : false);
+ }
+ }
+
+ class Driver extends SqlDriver {
+ static $extensions = array("json");
+ static $jush = "igdb";
+ private static $docsFilename = __DIR__ . DIRECTORY_SEPARATOR . 'igdb-api.html';
+
+ public $operators = array("=", "<", ">", "<=", ">=", "!=", "~");
+
+ public $tables = array();
+ public $links = array();
+ public $fields = array();
+ public $foreignKeys = array();
+ public $foundRows = null;
+
+ static function connect(string $server, string $username, string $password) {
+ if (!file_exists(self::$docsFilename)) {
+ return "Download https://api-docs.igdb.com/ and save it as " . self::$docsFilename; // copy() doesn't work - bot protection
+ }
+ return parent::connect($server, $username, $password);
+ }
+
+ function __construct($connection) {
+ parent::__construct($connection);
+ libxml_use_internal_errors(true);
+ $dom = new \DOMDocument();
+ $dom->loadHTMLFile(self::$docsFilename);
+ $xpath = new \DOMXPath($dom);
+ $els = $xpath->query('//div[@class="content"]/*');
+ $link = '';
+ foreach ($els as $i => $el) {
+ if ($el->tagName == 'h2') {
+ $link = $el->getAttribute('id');
+ }
+ if ($el->nodeValue == 'Request Path') {
+ $table = preg_replace('~^https://api.igdb.com/v4/~', '', $els[$i+1]->firstElementChild->nodeValue);
+ $this->fields[$table]['id'] = array('full_type' => 'bigserial', 'comment' => '');
+ $this->links[$link] = $table;
+ $this->tables[$table] = array(
+ 'Name' => $table,
+ 'Comment' => $els[$i-1]->tagName == 'p' ? $els[$i-1]->nodeValue : '',
+ );
+ foreach ($xpath->query('tbody/tr', $els[$i+2]) as $tr) {
+ $tds = $xpath->query('td', $tr);
+ $comment = $tds[2]->nodeValue;
+ if (!preg_match('~^DEPRECATED!~', $comment)) {
+ $field = $tds[0]->nodeValue;
+ $this->fields[$table][$field] = array(
+ 'full_type' => str_replace(' ', ' ', $tds[1]->nodeValue),
+ 'comment' => str_replace(' ', ' ', $comment),
+ );
+ $ref = $xpath->query('a/@href', $tds[1]);
+ if (count($ref) && !in_array($ref[0]->value, array('#game-version-feature-enums', '#tag-numbers'))) {
+ $this->foreignKeys[$table][$field] = substr($ref[0]->value, 1);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ function select($table, $select, $where, $group, $order = array(), $limit = 1, $page = 0, $print = false) {
+ $query = '';
+ $search = preg_match('~^MATCH \(search\) AGAINST \((.+)\)$~', $where[0], $match);
+ if ($search) {
+ $query = 'search "' . addcslashes($match[1], '\\"') . "\";\n";
+ unset($where[0]);
+ }
+ foreach ($where as $i => $val) {
+ $where[$i] = str_replace(' OR ', ' | ', $val);
+ }
+ $common = ($where ? "where " . implode(" & ", $where) . ";" : "");
+ $query .= "fields " . implode(",", $select) . ";"
+ . ($select == array('*') ? "\nexclude checksum;" : "")
+ . ($where ? "\n$common" : "")
+ . ($order ? "\nsort " . strtolower(implode(",", $order)) . ";" : "")
+ . "\nlimit $limit;"
+ . ($page ? "\noffset " . ($page * $limit) . ";" : "")
+ ;
+ $start = microtime(true);
+ $return = ($search || !array_key_exists($table, driver()->tables)
+ ? $this->conn->request($table, $query)
+ : $this->conn->request('multiquery', "query $table \"result\" { $query };\nquery $table/count \"count\" { $common };")
+ );
+ if ($print) {
+ echo adminer()->selectQuery("DELIMITER ;;\nendpoint $table;\n$query", $start);
+ }
+ if ($return === false) {
+ return $return;
+ }
+ $this->foundRows = ($search ? null : $return[1]['count']);
+ $return = ($search ? $return : $return[0]['result']);
+ if ($return) {
+ $keys = ($select != array('*') ? $select : array_diff(array_keys($this->fields[$table]), array('checksum')));
+ $return[0] = array_merge(array_fill_keys($keys, null), $return[0]);
+ }
+ return new Result($return);
+ }
+
+ function value($val, $field): ?string {
+ return ($val && in_array($field['full_type'], array('Unix Time Stamp', 'datetime')) ? str_replace(' 00:00:00', '', gmdate('Y-m-d H:i:s', $val)) : $val);
+ }
+
+ function tableHelp($name, $is_view = false) {
+ return strtolower("https://api-docs.igdb.com/#" . array_search($name, $this->links));
+ }
+ }
+
+ function logged_user() {
+ return $_GET["username"];
+ }
+
+ function get_databases($flush) {
+ return array("api");
+ }
+
+ function collations() {
+ return array();
+ }
+
+ function db_collation($db, $collations) {
+ }
+
+ function information_schema($db) {
+ }
+
+ function indexes($table, $connection2 = null) {
+ $return = array(array("type" => "PRIMARY", "columns" => array("id")));
+ if (in_array($table, array('characters', 'collections', 'games', 'platforms', 'themes'))) { // https://api-docs.igdb.com/#search-1
+ $return[] = array("type" => "FULLTEXT", "columns" => array("search"));
+ }
+ return $return;
+ }
+
+ function fields($table) {
+ $return = array();
+ foreach (driver()->fields[$table] ?: array() as $key => $val) {
+ $return[$key] = $val + array(
+ "field" => $key,
+ "type" => (preg_match('~^int|bool~i', $val['full_type']) ? $val['full_type'] : 'varchar'), // shorten reference columns
+ "privileges" => array("select" => 1, "update" => 1, "where" => 1, "order" => 1),
+ );
+ }
+ return $return;
+ }
+
+ function convert_field($field) {
+ }
+
+ function unconvert_field($field, $return) {
+ return $return;
+ }
+
+ function limit($query, $where, $limit, $offset = 0, $separator = " ") {
+ return $query;
+ }
+
+ function idf_escape($idf) {
+ return $idf;
+ }
+
+ function table($idf) {
+ return idf_escape($idf);
+ }
+
+ function foreign_keys($table) {
+ $return = array();
+ foreach (driver()->foreignKeys[$table] ?: array() as $key => $val) {
+ $return[] = array(
+ 'table' => driver()->links[$val],
+ 'source' => array($key),
+ 'target' => array('id'),
+ );
+ }
+ return $return;
+ }
+
+ function tables_list() {
+ return array_fill_keys(array_keys(table_status()), 'table');
+ }
+
+ function table_status($name = "", $fast = false) {
+ $tables = driver()->tables;
+ return ($name != '' ? ($tables[$name] ? array($name => $tables[$name]) : array()) : $tables);
+ }
+
+ function count_tables($databases) {
+ return array(reset($databases) => count(tables_list()));
+ }
+
+ function error() {
+ return connection()->error;
+ }
+
+ function is_view($table_status) {
+ return false;
+ }
+
+ function found_rows($table_status, $where) {
+ return driver()->foundRows;
+ }
+
+ function fk_support($table_status) {
+ return true;
+ }
+
+ function support($feature) {
+ return in_array($feature, array('columns', 'comment', 'sql', 'table'));
+ }
+}