Architecture of Adminer

Školení, která pořádám

This article was published in the August 2009 issue of php|architect.

Adminer is a feature complete MySQL administration tool with the aim of compactness. The whole application consists of a single file as small as possible. This request influences the code design of Adminer and this article describes the way of reaching that target.

As a freelance PHP programmer, I often get a task to check some problem or implement new functionality at a strange site. I usually get only FTP credentials without further documentation about the application or about the client's hosting. If the task consists of some database manipulation then I have to discover or modify the database structure without any tools or remote access to the database server. I can usually install phpMyAdmin in the web space but it currently consists of 11 MB in 666 files and I became quickly tired of uploading this package with every new task (and removing it after the task completion).

Therefore, I started to create a compact alternative of phpMyAdmin consisting of a single file easy to upload and delete anywhere. Initially, it was a simple tool with basic commands such as the table structure, select, SQL command and export. Later, I have added more and more features so now it is a feature complete MySQL administration tool including even the MySQL 5.1 features (events and table partitioning). Adminer implements some features (such as triggers or stored routines) in a more comfortable way than the bigger competitor but the basic goal of a single file is still valid.

Note: The project's original name was phpMinAdmin. I chose that name as a joke to emphasize the fact that phpMinAdmin is a minimalistic version of the famous PHP MySQL tool. (By the way do you use Lynx or Links?) This name was however confusing and invoked the feeling that phpMinAdmin is only a weaker alternative of phpMyAdmin.

Adminer Table Structure

Compilation

When the size of adminer.php started to grow then I have realized that it would be wise to split the file for manageable development. Nevertheless, I still wanted the all-in-one file so I have created a compilation script that joins the files back together.

Because everything lies in one file, Adminer cannot use the usual URL alter.php?table=X and the action must be passed in the query string. Instead of common ?action=alter&table=X, I have used the more compact alternative ?alter=X. The central script is then a sequence of if (isset($_GET["..."])) instead of one big switch ($_GET["action"]). There is an include of the specific functionality inside the if and these includes are compiled to a one file in the build process:

<?php
function include_file($match) {
    $file = file_get_contents($match[1]);
    $token = end(token_get_all($file));
    $php = (is_array($token) && in_array($token[0], array(T_CLOSE_TAG, T_INLINE_HTML)));
    return "?>\n$file" . ($php ? "<?php" : "");
}
$file = preg_replace_callback('~include "([^"]+)";~', 'include_file', $file);
?>

This code is not universally usable because it finds only the include "" variant. It would be necessary to process the file with token_get_all function and find all include variants. The _once variants would be more difficult. There can be also a global level return.

Adminer uses also some external files – style sheet, shortcut icon and couple of images. There are several possible approaches of accessing these files:

  1. They can be integrated in the main HTML code – style sheet by <style> tag, images by utilizing the data: protocol. The problem with this approach is that Internet Explorer < 8 does not support this protocol. The second problem is that the browser would need to transfer these data with each page repeatedly.
  2. The files can be downloaded from an external server. It would be problematic if the server is unreachable or if the computer running Adminer is without the Internet connection. This approach is however used with syntax highlighting of SQL queries, which is an optional feature – Adminer uses the JavaScript Syntax Highlighter JUSH for this task.
  3. The files can be integrated in the source code and a special parameter would serve them. Did you ever notice the PHP logo in the output of phpinfo? PHP downloads it from the local server with a special query string ?=PHP…. Adminer uses a similar approach and saves the files Base64 encoded in the source code (the encoding is not necessary but it simplifies the script editing in common text editors). This approach allows the HTTP caching of the external files which Adminer utilizes.

Note: PHP 5.3 comes with a support for PHP archives through the phar extension. With this extension, it is possible to pack several files to one archive and access the files inside the archive from PHP. Usage of the webPhar method allows packing most of the PHP applications to one archive and there are instructions for creating phar version of phpMyAdmin in the PHP manual. Adminer does not use this extension and works with PHP >= 4.3 and PHP >= 5.0.

Minification

The compilation script also minifies the code – it removes comments and trims white space. The minification code is based on a work of David Grudl who uses it for his brilliant libraries Texy, dibi and Nette. This function is more effective than the internal function php_strip_whitespace.

<?php
function php_shrink($input) {
    $set = array_flip(preg_split('//', '!"#$&\'()*+,-./:;<=>?@[\]^`{|}'));
    $space = '';
    $output = '';
    foreach (token_get_all($input) as $token) {
        if (!is_array($token)) {
            $token = array(0, $token);
        }
        if ($token[0] == T_COMMENT || $token[0] == T_WHITESPACE) {
            $space = "\n";
        } else {
            if (isset($set[substr($output, -1)]) || isset($set[$token[1][0]])) {
                $space = '';
            }
            $output .= $space . $token[1];
            $space = '';
        }
    }
    return $output;
}
?>

Current version also shortens the names of user variables and functions. It finds the variables and functions in the source code with token_get_all function and replaces them by shorter identifiers. The shortening process skips the internal PHP variables and functions. It would be possible to shorten them too but it would be more complicated and it would have a performance penalty. Some PHP variables have their super global feature so it would be necessary to globalize their aliases inside the functions.

The internal functions can be enclosed in envelope functions with shorter names. This approach is however problematic because the PHP functions can have optional parameters without the default values (example of this function is fwrite which detects the number of passed arguments instead of their values). Universal function func_get_args does not work with the references on the contrary.

Alternative approach would be to define a variable for an internal function (e.g. $fw = 'fwrite') and call the function through this variable: $fw($fp, $string). Adminer however does not use neither approach because it would slow the application down.

The minification trims white space also from CSS and PHP version of JSMin minifies the JavaScript code too.

The file would be much smaller if it would be compressed. PHP supports several compression algorithms but only through an extension. I was thinking about a simple decompression function written in PHP which will unpack the rest of the file but it will need to eval the decompressed code and everybody knows that eval is evil (one reason is that it is not compatible with PHP accelerators). Therefore, the code is not compressed but only minified.

Translations

I develop Adminer in English. There is also a Czech version (which is my native language) from the beginning and Adminer is currently available in 11 languages. All messages are served by a simple function, which checks the current language and returns the correspondent translation.

A simple function detects the language from the Accept-Language header:

<?php
function acceptable_language($translations) {
    $accept_language = array();
    preg_match_all('~([-a-z]+)(;q=([0-9.]+))?~', strtolower($_SERVER["HTTP_ACCEPT_LANGUAGE"]), $matches, PREG_SET_ORDER);
    foreach ($matches as $match) {
        $accept_language[$match[1]] = (isset($match[3]) ? $match[3] : 1);
    }
    arsort($accept_language);
    foreach ($accept_language as $lang => $q) {
        if (isset($translations[$lang])) {
            return $lang;
        }
        $lang = preg_replace('~-.*~', '', $lang);
        if (!isset($accept_language[$lang]) && isset($translations[$lang])) {
            return $lang;
        }
    }
    return false;
}
?>

The caller of this function saves the language to a cookie after the first detection. A user can change the language anytime while staying on the same page. Saving the page language to a cookie is a very bad practice on a public site because the search engines then can index only one version of the page. It is however acceptable in Adminer because the search engines do not index the pages and the main content of the page (e.g. the table structure) is the same in all languages. The reward for storing the language in a cookie is a simpler URL while it is still possible to enforce the language by passing the language identifier in the query string.

Translations are stored in a simple array where the key is a message identifier (which is an English version of the message and it is used if the translation does not exist) and the value is a string with the translation. The value is more interesting with messages containing a number (e.g. “1 row” or “2 rows”). Most languages have different singular and plural form but some languages (e.g. Czech or Russian) use more plural forms depending on the number (e.g. Czech “1 pivo”, “2 piva” or “5 piv” for beers). The messages with numbers are stored in an array instead of string and Adminer contains a simple logic for each language choosing the right form depending on the number.

By default, Adminer comes with all languages. The compilation converts the translation array to values only and changes the identifiers to numbers to save some bytes so each translation takes about 4 KB. The compilation script is able to create also a single language version, which removes the translating function and the language detection completely. This file takes only 111 KB for the English version of Adminer 1.10.1.

Database extension abstraction

PHP allows connecting to a MySQL database through three extensions: mysql, mysqli and pdo_mysql. Adminer supports all of them and contains a simple abstraction layer for these extensions. The abstraction layer emulates the subset of mysqli and mysqli_result classes for all three extensions.

PDO support is little tricky because PDO uses exceptions for reporting connection errors. Adminer cannot use the usual try block to catch these exceptions because it runs also on PHP 4. Everything is in a single file so the PDO support cannot be conditionally included only in PHP 5. The solution is to use set_exception_handler function to process the connection errors. PHP 4 also does not support class constants on the syntax level so the source code uses the numerical values of the constants instead.

Security

The most important part of the security in Adminer is surprisingly the defense against Cross-site Request Forgery. CSRF allows an attacker to perform actions in the name of a logged-in user. Adminer stores the login information in a standard session so PHP can send the session identifier in a cookie and the defense against CSRF is necessary. Adminer sends a token with each form and checks this token before performing the operation of the form. A token protects even the logout action which is often forgotten in other web applications.

Very important is also the protection against Cross-site Scripting. Outputting the HTML code stored in the database could reveal sensitive information to the attacker so htmlspecialchars function escapes every output.

Adminer has also a protection against the SQL injection but it is not in the name of security – if the attacker already logs-in to the Adminer then she could execute any SQL code by the SQL command page. Therefore, the protection against the SQL injection is only a side effect of proper manipulation with the user input – a user can pass any data to the database. If Adminer detects the magic_quotes_gpc then it neutralizes this directive and uses a proper escaping function only when passing the data to MySQL.

<?php
if (get_magic_quotes_gpc()) {
    $process = array(&$_GET, &$_POST, &$_COOKIE, &$_REQUEST);
    while (list($key, $val) = each($process)) {
        foreach ($val as $k => $v) {
            unset($process[$key][$k]);
            if (is_array($v)) {
                $process[$key][stripslashes($k)] = $v;
                $process[] = &$process[$key][stripslashes($k)];
            } else {
                $process[$key][stripslashes($k)] = stripslashes($v);
            }
        }
    }
    unset($process);
}
?>

Calling of session_regenerate_id after the login prevents session fixation.

Every page of Adminer contains the robots: noindex meta information to hide the page from search engines.

Login form

Adminer saves the database credentials to a session and checks them on every page. If the database cannot authorize the user then the login form is displayed directly on the page. This approach is better then redirecting the user to a login page for several reasons:

  1. It saves one request, the login form is sent directly to the target page.
  2. User can bookmark any page, which displays directly after the login. The redirection to a login form approach can achieve this too but it is much more complicated (the login form needs to get the returning URL, check it for validity and redirect back after the login because the Referer header is not reliable).
  3. If the session expires before sending any application form then the posted data prepopulates the login form with the hidden fields.
<?php
function hidden_fields($process) {
    while (list($key, $val) = each($process)) {
        if (is_array($val)) {
            foreach ($val as $k => $v) {
                $process[$key . "[$k]"] = $v;
            }
        } else {
            echo '<input type="hidden" name="' . htmlspecialchars($key) . '" value="' . htmlspecialchars($val) . '" />';
        }
    }
}
hidden_fields($_POST);
?>

The sent files are also stored to hidden fields and there is a little layer serving the files from the $_FILES variable or from the posted hidden fields.

It is possible to log in without filling the form by passing the ?username= parameter. A user can use this feature in a secure environment where he authenticates by some other mean (e.g. on localhost without a remote access or with HTTP authentication). However, it is not possible to pass the password in a GET parameter which would be very insecure. Adminer uses the value of mysql.default_password configuration directive in this case. The demo application uses this approach to automatically log in the users.

Form processing

Adminer sends all action forms by the POST method. If the action is successful then it redirects the browser to a page showing the result with a confirmation message. This message is stored to a session variable after performing the action and the beginning of each HTML page processes this session variable. There is a theoretical chance that other Adminer page opened simultaneously in a different browser tab can catch the message and display it instead of the original page. However, the period between setting the message and displaying it is very short so this probability is low. Besides, the consequence of this coincidence would be small – the user would get informed about the result, just in a different tab.

An alternative to this approach would be to send a message ID or the message itself in the URL but it would clutter the browser history. In addition, the page refresh would maintain the message on the page, which is not desirable.

If the form action produces an error then the page directly displays the error message with the form prefilled by the sent data. The code does not redirect the browser in this case, which allows a user to refresh the page with the sent data if the error was only temporary (for example if it could be fixed in other browser tab).

Database schema

Simple database schema Adminer contains a simple interface for visualization of the database schema. It displays all tables with their columns. Colors differentiate the column types and arrows symbolize the table references. Tables can be moved around the screen with a mouse, which is achieved by JavaScript and CSS. The database schema page uses a cookie to save the table placement.

Export

Adminer supports SQL and CSV import and export. The most interesting variant of export is the one using the ALTER command. There is often a different database on the production and the development server. If you have made some changes in the database structure which should go to the production server during a release then dropping the old table and creating the new one is not an option because there are live data in this table. Adminer is able to generate a set of ALTER commands synchronizing the databases. It utilizes a stored procedure which explores the production database and alters it to conform to the development version. New tables are created as usual, old tables are dropped, missing fields and indexes are added.

Performance

Adminer always queries all data directly from the database with one exception. Getting the database list could take a very long time if there are many databases on the server even if the user has access only to couple of them. Because each page displays the database list then it is cached to a session variable. This variable is refreshed if a user creates or drops a database either from the user interface or by the SQL command.

Closing a session by session_write_close is a performance optimization with a little impact. The default PHP session handler locks the file with a session data, which prevents the web server to serve multiple requests of the same user at the same time. This is very important on sites using frames where it is a common situation. Adminer does not use frames but a user can still open several browser tabs with Adminer at the same time. Adminer writes all the session data and closes the session as soon as possible to allow the maximum concurrency.

Most users feel the smoothness of Adminer and one user even created the test suite, which measures the performance with comparison to phpMyAdmin:

Demonstration

The demo is one of the best ways to present the features of some product. I have created some sample database and made it accessible from the website through Adminer but users have started corrupting it quickly. I was thinking about periodically restoring the database but it would not work because in the meantime the database would be still corrupted. On the other side, users working with the database during the reset would be surprised what is happening. Restricting the actions would cripple the demo, so it was not an option either. Other possibility was to emulate the database access in a session for each user but it would be a very complex task and it will not present all the features of Adminer.

Finally, I have ended with creating a separate database for each user entering the demo. The script initializes this database by a sample data after login to the demo and destroys it after logout. A cron job drops the databases of users who did not log out. The demo user has powerful privileges to present the most features of Adminer so it was necessary to restrict the list of databases to disallow accessing databases of other users. Each MySQL user can also modify her password and users of the demo application quickly discovered it. Thus, the demo disables also this feature.

I did not want to create a separate version of Adminer just for the demo so I have made all customizations by auto_prepend_file. The prepended script restricts the database list, disallows changing of password, initializes the demo databases and drops them.

Tests and code coverage

Nearly all parts of the application have tests. I have created the tests in Selenium IDE, which is a very convenient Firefox plug-in for creating complex web applications tests. The main advantage of Selenium is that it can test the whole application – from PHP on the server side to HTML on the client side, it can test even the JavaScript interactions. Adminer works well without JavaScript but some features are more comfortable with the JavaScript enabled – for example adding a field in a table does not require sending the page to the server.

Selenium IDE can create the tests very easily. It is possible to just push the record button and work in a browser as usually. The recorded test can be modified afterwards so if you are checking some feature in the application then why not to record a test for it?

I was curious how much code is tested and I was interested in which parts of Adminer need tests that are more thorough. It can be find out by the code coverage. I have used the Xdebug extension, which has the ability to measure the code coverage. It is well suited for running unit tests because the code coverage is computed for the whole run of the tests. But the web application tests are different as they compose from several requests and the code coverage is computed for each request separately. The solution was quite easy – I have registered a shutdown function saving the code coverage to a session and joining it with the previous results.

The code coverage of Adminer is around 75 %, which is satisfactory. For comparison, the code coverage of PHP source codes is around 70 %. I was very glad that the tests exist when I refactored some parts of the application. I was quite sure that the refactoring did not break anything when all the tests have passed.

Version checker

There is a RSS feed with the project news and e-mail subscriptions for the new releases. Some users however just install the current version and forget about it. For this reason, the Adminer contains a version checker.

The version checker must be as unobtrusive as possible because sometimes the Adminer is run in an environment without the Internet connection or the server could be unreachable. So checking the current version from PHP code was not an option because it is blocking. Furthermore, an administrator can disable the remote connections in PHP by allow_url_fopen configuration directive. Thus, the version checker runs from JavaScript. A simple <script src> is blocking too (a user can work with the page but the page is still in the loading state) which is the same for images too. So the script is loaded in an onload event which is non-blocking. The server part is quite simple:

<?php
header("Content-Type: text/javascript");
if (version_compare($_GET["version"], $VERSION) < 0) {
    echo "document.getElementById('version').innerHTML = '$VERSION';\n";
}
echo "document.cookie = 'adminer_version=$VERSION';\n";
?>

If the current version is old then the script modifies the document to display the new version number. The client side checks the sent cookie so the communication with the server is only occasional.

Design

Design from Vlasta Neubauer I am not an artist so the style sheet comes from freelance graphic designer Ondřej Válka. The design is simple and tidy as the whole Adminer so I like it. Other users however like more fancy designs so they created their own. The design can be easily integrated to the single file by own compilation but users like to test several designs so Adminer checks if the external file adminer.css exists. If it does exist then the script uses it instead of the integrated CSS.

Conclusion

This article is about the Adminer architecture, not about its features. However, it should be clear that Adminer supports all MySQL features from a simple table editing, through multiple-columns foreign keys, triggers, stored routines, export, user and processes management to the MySQL 5.1 events and table partitioning. There is of course a universal SQL command for non-standard tasks.

Adminer is not just a compact version of phpMyAdmin, it tries to be a fully competitive alternative for the MySQL administration and the small footprint is only a pleasant bonus.

About the author

Jakub Vrána is one of the authors of official PHP manual, and he uses MySQL for developing web applications in PHP. He also teaches the PHP and MySQL basics at Charles University and conducts commercial trainings. He is the founder of compact MySQL management tool Adminer. Jakub lives in Prague, Czech Republic.

Jakub Vrána, Adminer, 28.12.2009, comments: 24 (new: 0)

Comments

Nox:

Zajímavý článek

Odkaz v úvodním odstavci odkazuje ještě na starou adresu

ikona Jakub Vrána OpenID:

Díky za upozornění, opravil jsem to.

3CK:

Možná je to trochu OT, ale když je řeč o tom Adminerovi:

Adminer převádí názvy všech tabulek na malá písmena, což je občas trochu matoucí, zvlášť při skládání dotazů dochází ke zbytečným chybám.

Předpokládám, že mi odpovíte, že konvence velí používat místo velbloudí notace spíše podtržítka mezi slovy, ale přesto - mnoho aplikací používá právě velbloudí notaci.

Počítá se s tím do budoucna něco dělat, je to feature nebo bug?

ikona Jakub Vrána OpenID:

Adminer s tím nemá nic společného. Ovlivňuje to nastavení MySQL lower_case_table_names.

3CK:

ok díky, o ničem takové direktivě sem vůbec neměl potuchy

Jan Garaj:

Pri čítaní performance časti ma napadla možná optimalizácia - použitie ob_gzhandlera, resp. kompresie výstupu.
Testom na úvodnej login obrazovke Adminera som zistil úsporu 50% dát (=895B). Len či by aj reálne zrýchlila takáto optimalizácia rýchlosť aplikácie, keďže html výstup nie je dátovo veľký. Možno by čas ušetrený na prenose presiahol čas nutný na dekompresiu dát na strane prehliadača. No do takejto hĺbky testovania som už nešiel.

ikona Jakub Vrána OpenID:

Také už jsem o tom přemýšlel. Ale je to věc, kterou si může každý svobodně nakonfigurovat přímo v PHP, takže to v Admineru měnit nebudu.

ikona Štěpán Kocourek:

Když jsem ten článek četl, všiml jsem si, že na několika málo místech používáš ženská zájmena, i když mluvíš o "tom", případně o "něm". Když jsem se je teď pokusil najít znovu, našel jsem po paměti jen jedno (citace viz níže), ale vzpomínám se, že jich tam bylo víc.

"...Each MySQL user can also modify HER password and users of the demo application quickly discovered it..."

ikona Jakub Vrána OpenID:

Pokud vím, tak se to takhle v angličtině používá. Když se mluví o uživateli/uživatelce, tak se v češtině používá on, ale v angličtině ona. Ale možná by nám to rodilý mluvčí vysvětlil lépe.

Ap.Muthu:

Current version 3.7.0 does not display a date field if referenced by foreign key. Only String fields get displayed in dropdown select boxes.

References to Views are not recognised as well.

ikona Jakub Vrána OpenID:

Can you be more specific about it? Is it Adminer or Adminer Editor? What do you mean by "does not display", what's displayed instead?

Which database system are you using? MySQL doesn't support references to views.

Please describe reproducible steps, preferably with screenshots.

arun:

how can ue see the routine (store procedure) in adminer???? plz tell me where they save??

arun:

adminer 3.3.3 does not display routines

ikona Jakub Vrána OpenID:

There's a list of them on the database overview page. You can create and alter them there too. They are available as of MySQL 5.0.

Adminer 3.3.3 displays them.

anielle:

Adminer 3.7.1 on windows gives this error on import a CSV, file:

'File must be in UTF-8 encoding.'

The file already IS in UTF-8 format. Checked and re-checked several times.

Disappointing.

ikona Jakub Vrána OpenID:

Can you share the file with me? There might be something wrong with it.

Ralph:

did you find a solution to this? I am saving from Excel 2010 in UTF-8 format and getting the same response

ikona Jakub Vrana OpenID:

Can you share the file with me? There might be something wrong with it.

Ralph:

I was able to resolve the error by opening the file in Notepad and re-saving in UTF8 format. Must be a problem with something that Excel added.

Lrkwz:

Same problem.

Lrkwz:

File has been produced with:

$ mysqldump --single-transaction mydb > data-`date +%Y%m%d`.sql

$ file data-20131016.sql
data-20131016.sql: UTF-8 Unicode English text, with very long lines

johnleo:

Hi.nice work here.i'm using this with droidphp(lighttpd+php+mysql) on an android dev.any css for mobile?

JoaoC:

I have tables with several URL fiels. Is there a way to display friendly URLs on selected data tables?
By the way, adminer is really great!

ikona Jakub Vrána OpenID:

What do you mean by “display friendly URLs on selected data tables”?

Diskuse je zrušena z důvodu spamu.

avatar © 2005-2024 Jakub Vrána. Publikované texty můžete přetiskovat pouze se svolením autora. Ukázky kódu smíte používat s uvedením autora a URL tohoto webu bez dalších omezení Creative Commons. Můžeme si tykat. Skripty předpokládají nastavení: magic_quotes_gpc=Off, magic_quotes_runtime=Off, error_reporting=E_ALL & ~E_NOTICE a očekávají předchozí zavolání mysql_set_charset. Skripty by měly být funkční v PHP >= 4.3 a PHP >= 5.0.