sof3 / libasynql
Installs: 5 386
Dependents: 4
Suggesters: 0
Security: 0
Stars: 134
Watchers: 14
Forks: 45
Open Issues: 14
Requires
- pocketmine/pocketmine-mp: ^5.0.0-BETA4
- sof3/await-generator: ^2.0.0 || ^3.0.0
README
Asynchronous SQL access library for PocketMine plugins.
Why should I use this library and what does asynchronous mean?
When executing a SQL query on the main thread, there will be a delay to wait for the MySQL server or SQLite for interacting with the file system. The delay will block the main thread and cause lag to the server.
Libasynql uses different threads for executing the queries so the main thread will not lag!
Look in here as well if you want to learn more about threading.
Usage
The basic use of libasynql has 5 steps:
- Add default database settings in your
config.yml
. - Write down all the SQL queries you will use in a resource file
- Initialize the database in
onEnable()
. - Finalize the database in
onDisable()
. - Obviously, and most importantly, use libasynql in your code.
Configuration
To let the user choose what database to use, copy the following into your default config.yml
. Remember to change the default schema name under mysql
.
database: # The database type. "sqlite" and "mysql" are supported. type: sqlite # Edit these settings only if you choose "sqlite". sqlite: # The file name of the database in the plugin data folder. # You can also put an absolute path here. file: data.sqlite # Edit these settings only if you choose "mysql". mysql: host: 127.0.0.1 # Avoid using the "root" user for security reasons. username: root password: "" schema: your_schema # The maximum number of simultaneous SQL queries # Recommended: 1 for sqlite, 2 for MySQL. You may want to further increase this value if your MySQL connection is very slow. worker-limit: 1
Initialization and Finalization
libasynql simplifies the process of initializing a database into a single function call.
use pocketmine\plugin\PluginBase; use poggit\libasynql\libasynql; class Main extends PluginBase{ private $database; public function onEnable(){ $this->saveDefaultConfig(); $this->database = libasynql::create($this, $this->getConfig()->get("database"), [ "sqlite" => "sqlite.sql", "mysql" => "mysql.sql" ]); } public function onDisable(){ if(isset($this->database)) $this->database->close(); } }
The \poggit\libasynql\libasynql::create()
method accepts 3 parameters:
- Your plugin main (basically
$this
if the code runs in onEnable()) - The config entry where the database settings should be found (read the example from above)
- An array for your SQL files. For each SQL dialect you are supporting, use it as the key, and use the path (or array of paths, relative to the
resources
folder) of the SQL files as the value. We are going to create them in the next step.
It returns a \poggit\libasynql\DataConnector
object, which is the main query interface. You may store this object in a property for later use, $this->database
for example.
In case of error, a ConfigException or an SqlError will be thrown. If not caught by the plugin, this will go straight out of onEnable() and disable the plugin. Therefore, make sure to check isset($this->database)
before calling $this->database->close()
in onDisable().
Creating SQL files
In the resources file, create one file for each SQL dialect you are supporting, e.g. resources/sqlite.sql
and resources/mysql.sql
.
Do I save the SQL files to the plugin data folder?
No, you do not have to copy the SQL files to the plugin data folder (i.e. do not add $this->saveResource("db.sql")
). The files are read by libasynql from the phar resources directly.
Write down all the queries you are going to use in each file, using the Prepared Statement File format.
Calling libasynql functions
Finally, we are prepared to use libasynql in code!
There are 4 query modes you can ues: GENERIC, CHANGE, INSERT and SELECT.
- GENERIC: You don't want to know anything about the query except whether it is successful. You may want to use this in
CREATE TABLE
statements. - CHANGE: Your query modifies the database, and you want to know how many rows are changed. Useful in
UPDATE
/DELETE
statements. - INSERT: Your query is an
INSERT INTO
query for a table with anAUTO_INCREMENT
key. You will receive the auto-incremented row ID. - SELECT: Your query expects a result set, e.g. a
SELECT
statement, or reflection queries likeEXPLAIN
andSHOW TABLES
. You will receive aSqlSelectResult
object that represents the columns and rows returned.
They have their respective methods in DataConnector: executeGeneric
, executeChange
, executeInsert
, executeSelect
. They require the same parameters:
- The name of the prepared statement
- The variables for the query, in the form of an associative array "variable name (without the leading colon)" => value
- An optional callable triggered if the query succeeded, accepting different arguments:
- GENERIC: no arguments
- CHANGE:
function(int $affectedRows)
- INSERT:
function(int $insertId, int $affectedRows)
- SELECT:
function(array $rows)
- An optional callable triggered if an error occurred. Can accept an
SqlError
object.
Prepared Statement File Format
A Prepared Statement File (PSF) contains the queries that a plugin uses. The content is valid SQL, so it is OK to edit with a normal SQL editor.
The PSF is annotated by "command lines", which start with -- #
, followed by the command symbol, then the arguments. Between the #
and the command symbol, there can be zero to infinite spaces or tabs; between the command symbol and the arguments, there can also be zero to infinite spaces or tabs. Between every two arguments, one to infinite spaces or tabs are required.
Dialect declaration
A PSF always starts with a dialect declaration.
Symbol
!
Arguments
DIALECT
Possible values: mysql
, sqlite
Example
-- #! mysql
Group declaration
Queries may be organized by groups. Each group has an identifier name, and a group can be stacked under another. Groups and queries under a group will be prepended the parent group's identifier plus a period in their own identifiers.
For example, if a parent group declares an identifier foo
, and the child group/query declares an identifier bar
, the real identifier for the child group/query is foo.bar
.
Duplicate group identifier declarations are allowed, as long as the resultant queries do not have identical full identifiers.
Symbol
- Start:
{
- End:
}
Arguments (Start)
IDENTIFIER_NAME
The name of this group.
All characters except spaces and tabs are allowed, including periods.
Example
-- #{ group.name.here -- #{ child.name -- the identifier of the child group is "group.name.here.child.name" -- #} -- #}
Note that PSF is insensitive about spaces and tabs, so this variant is equivalent:
-- #{ group.name.here -- # { child.name -- the identifier of the child group is still "group.name.here.child.name" -- # } -- #}
Query declaration
A query is declared like a group. A query does not need to belong to a group, because the query can declare the periods in its own identifier, which has equivalent effect as groups.
Child groups are not allowed in a query declaration. In other words, a {}
pair either has other group/query declarations inside, or has query text (and optionally variable declarations) inside. It cannot have both.
Symbol
- Start:
{
(same as group declaration) - End:
}
Arguments
Same arguments as a group declaration.
Variable declaration
A variable declaration declares the required and optional variables for this query. It is only allowed inside a query declaration.
Symbol
:
Arguments
VAR_NAME
The name of the variable. Any characters apart from spaces, tabs and colons are allowed. However, to comply with ordinary SQL editors, using "normal" symbols (e.g. variable names in other programming languages) is recommended.
VAR_TYPE
The variable type. Possible values:
string
int
float
bool
VAR_DEFAULT
If the variable is optional, it declares a default value.
This argument is not affected by spaces. It starts from the first non-space non-tab character after VAR_TYPE, and ends before the trailing space/tab characters of the line
string
default
There are two modes, literal string and JSON string.
If the argument starts with a "
and ends with a "
, the whole argument will be parsed in JSON. Otherwise, the whole string is taken literally.
int
default
A numeric value that can be parsed by (int)
cast, equivalent to intval
.
float
default
A numeric value that can be parsed by (float)
cast, equivalent to floatval
.
bool
default
true
, on
, yes
or 1
will result in true. Other values, as long as there is something, will result default false. (If there is nothing, the variable will not be optional)
Example of using variables
SQL file
-- #! sqlite -- #{ example -- # { insert -- # :foo string -- # :bar int INSERT INTO example( foo_column bar_column ) VALUES ( :foo, :bar ); -- # } -- # { select -- # :foo string -- # :bar int SELECT * FROM example WHERE foo_column = :foo LIMIT :bar; -- # } -- #}
Code
// Example of using variable in insert statements $this->database->executeInsert("example.insert", ["foo" => "sample text", "bar" => 123]); // Example of using variable in select statements $this->database->executeSelect("example.select", ["foo" => "sample text", "bar" => 1], function(array $rows) : void { foreach ($rows as $result) { echo $result["bar_column"]; } });
Query text
Query text is not a command, but the non-commented part between the start and end commands of a query declaration.
Variables are interpolated in query text using the :var
format. Note that libasynql uses a homebrew algorithm for identifying the variable positions, so they might be inaccurate.
-- #{ query.declarartion SELECT * FROM example; -- The line above is a query text -- #}
Things to beware
Race condition
public $foo = 'bar'; public function setFoo() : void { $this->foo = 'foo'; } public function getFoo() : string { return $this->foo; }
$this->database->executeGeneric("beware.of.race_condition", [], function() : void { $this->setFoo(); }); echo $this->getFoo();
The result will be bar
because the queries are run asynchronously. The code on the main thread will run earlier than it.
To make the code give a correct result, you have to ensure $this->setFoo()
runs before echo $this->getFoo()
. The appropriate way is to move getFoo()
into the callback function, just like below:
$this->database->executeGeneric("beware.of.race_condition", [], function() : void { $this->setFoo(); echo $this->getFoo(); });
Returning the result from callback
Due to race condition (as explained in the section above), it is not viable to return the result nor use it beyond the scope of the callback. If you are aiming to create an API function or seek to share the results with other parts of the code, it is advisable to convert the code into callbacks as well:
public function myAPI(\Closure $userCallback) $this->database->executeSelect("beware.of.return_from_callback", [], function($result) use ($userCallback) : void { $userCallback($result); }); // Simpler versions: $this->database->executeSelect("beware.of.return_from_callback", [], fn($result) => $userCallback($result)); $this->database->executeSelect("beware.of.return_from_callback", [], $userCallback); }
Callbacks will clutter your code
While using callbacks might be one straightforward solution to your problems, there exists a significant trade off — you must sacrifice the code readability. Hence, we recommend learning the async/await code style and use it to reduce the mess.