SQL Playground — PHP source
Run real SQL on sample datasets - or your own schema - right in the browser. Write queries, see formatted results instantly, and export or share them. Powered by sql.js (SQLite WASM); 100% client-side.
This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
/**
* sql-playground — polyglot showcase port (PHP)
*
* Pure helper logic for the CosmoDev "SQL Playground" tool: splitting a SQL
* script into statements, classifying read-only ones, serializing result sets
* to CSV / Markdown / JSON, and extracting a schema summary from CREATE TABLE
* scripts.
*
* Ported from src/lib/sql-playground.ts (the canonical TypeScript lib that
* powers the live tool). Functionally equivalent: same inputs -> same outputs.
*
* Display source — part of CosmoDev's polyglot tool pages.
*
* NOTE on value types: PHP only has int/float/string/bool/null/array/object.
* There is no native distinction between a "row array" and an "object", so
* cells are passed as plain PHP values (int|float|string|bool|null|array).
* Arrays/objects are JSON-encoded for display, mirroring the TypeScript
* behaviour where any non-primitive becomes JSON.
*/
namespace CosmoDev\SqlPlayground;
/**
* Split a SQL script into top-level statements on ';', respecting single-quote
* string literals. A doubled '' inside a string is an escaped literal quote
* (SQL standard) and does not end the string. Whitespace-only statements are
* dropped.
*
* @param string $sql
* @return string[] Trimmed, non-empty statements.
*/
function split_statements(string $sql): array
{
$stmts = [];
$current = '';
$inString = false;
$len = strlen($sql);
for ($i = 0; $i < $len; $i++) {
$ch = $sql[$i];
if ($inString) {
$current .= $ch;
if ($ch === "'") {
if ($i + 1 < $len && $sql[$i + 1] === "'") {
// Escaped literal quote: consume both chars.
$current .= $sql[$i + 1];
$i++;
} else {
$inString = false;
}
}
} elseif ($ch === "'") {
$inString = true;
$current .= $ch;
} elseif ($ch === ';') {
$trimmed = trim($current);
if ($trimmed !== '') {
$stmts[] = $trimmed;
}
$current = '';
} else {
$current .= $ch;
}
}
// Flush any trailing statement that wasn't terminated by ';'.
$trimmed = trim($current);
if ($trimmed !== '') {
$stmts[] = $trimmed;
}
return $stmts;
}
/**
* True when the statement is read-only (safe to run against a snapshot without
* mutating state). Recognises the common query-leading keywords: SELECT,
* WITH, VALUES, EXPLAIN, PRAGMA.
*/
function is_read_only_statement(string $sql): bool
{
return (bool) preg_match('/^(SELECT|WITH|VALUES|EXPLAIN|PRAGMA)\b/i', ltrim($sql));
}
/**
* Render one cell value as the textual form used in tables/CSV:
* - null -> "NULL"
* - bool/int/float -> native string form
* - string -> verbatim
* - array/object -> JSON
*
* @param mixed $v
*/
function format_scalar($v): string
{
if ($v === null) {
return 'NULL';
}
if (is_bool($v)) {
return $v ? 'true' : 'false';
}
if (is_int($v) || is_float($v)) {
// PHP renders floats like 1.0 as "1", matching JS Number->String for
// whole numbers; for non-whole floats it produces shortest form too.
return (string) $v;
}
if (is_string($v)) {
return $v;
}
// Arrays / objects -> JSON. JSON_THROW_ON_ERROR would change the return
// shape on failure; mirror the TS path which always succeeds for arrays.
$encoded = json_encode($v);
return $encoded === false ? 'NULL' : $encoded;
}
/**
* Quote one cell per RFC-4180-ish CSV: if the formatted value contains a
* comma, double quote, or newline, wrap it in double quotes and double any
* embedded double quotes.
*
* @param mixed $v
*/
function csv_field($v): string
{
$s = format_scalar($v);
if (preg_match('/[,"\n\r]/', $s) === 1) {
return '"' . str_replace('"', '""', $s) . '"';
}
return $s;
}
/**
* Serialize a result set to CSV (header row + one row per record, trailing LF).
*
* @param string[] $columns
* @param array<array<mixed>> $rows
*/
function rows_to_csv(array $columns, array $rows): string
{
$lines = [implode(',', array_map(__NAMESPACE__ . '\\csv_field', $columns))];
foreach ($rows as $row) {
$lines[] = implode(',', array_map(__NAMESPACE__ . '\\csv_field', $row));
}
return implode("\n", $lines) . "\n";
}
/**
* Escape a pipe so it doesn't break the Markdown table layout.
*/
function md_escape(string $s): string
{
return str_replace('|', '\\|', $s);
}
/**
* Serialize a result set as a GitHub-flavored Markdown table.
*
* @param string[] $columns
* @param array<array<mixed>> $rows
*/
function rows_to_markdown(array $columns, array $rows): string
{
$header = '| ' . implode(' | ', array_map(__NAMESPACE__ . '\\md_escape', $columns)) . ' |';
$sep = '| ' . implode(' | ', array_fill(0, count($columns), '---')) . ' |';
$lines = [$header, $sep];
foreach ($rows as $row) {
$cells = [];
foreach ($row as $cell) {
$cells[] = md_escape(format_scalar($cell));
}
$lines[] = '| ' . implode(' | ', $cells) . ' |';
}
return implode("\n", $lines) . "\n";
}
/**
* Serialize a result set as a pretty-printed JSON array of objects keyed by
* column name. Missing cells (row shorter than columns) become null.
*
* @param string[] $columns
* @param array<array<mixed>> $rows
*/
function rows_to_json(array $columns, array $rows): string
{
$out = [];
foreach ($rows as $row) {
$obj = [];
foreach ($columns as $i => $col) {
// ?? null mirrors the TS `row[i] ?? null`: a missing or null cell
// serialises as JSON null.
$obj[$col] = array_key_exists($i, $row) ? $row[$i] : null;
}
$out[] = $obj;
}
// JSON_PRETTY_PRINT = 2-space indent, matching JSON.stringify(arr, null, 2).
// JSON_UNESCAPED_SLASHES keeps paths like "a/b" readable (TS leaves them).
return json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* Pull the column name (lower-cased) from a single CREATE TABLE column
* definition. Returns null for table-level constraint lines (PRIMARY KEY,
* FOREIGN KEY, UNIQUE, CHECK, CONSTRAINT), which are not columns.
*
* @return string|null
*/
function extract_column_name(string $def): ?string
{
$def = trim($def);
if ($def === '') {
return null;
}
if (preg_match('/^(PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT)\b/i', $def) === 1) {
return null;
}
if (preg_match('/^["\'`]?(\w+)["\'`]?/', $def, $m) === 1) {
return strtolower($m[1]);
}
return null;
}
/**
* Best-effort extraction of `['table' => name, 'columns' => [...]]` summaries
* from a CREATE TABLE script. Handles IF NOT EXISTS, quoted identifiers, and
* parenthesised type/constraint bodies (depth-tracked so a `,` inside
* `NUMERIC(10,2)` doesn't split a column). Returns an empty array on any
* error.
*
* @return array<int, array{table: string, columns: string[]}>
*/
function summarize_schema(string $createSql): array
{
// PHP has no global regex `lastIndex` cursor like JS, so we scan with
// preg_match + an explicit byte offset (PREG_OFFSET_CAPTURE) and walk the
// string manually for the table body.
$results = [];
$headerRe = '/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[\"\'\`]?\b(\w+)\b[\"\'\`]?\s*\(/i';
$offset = 0;
while (true) {
if (preg_match($headerRe, $createSql, $m, PREG_OFFSET_CAPTURE, $offset) !== 1) {
break;
}
[$tableMatch, $matchStart] = $m[0];
$table = strtolower($m[1][0]);
$bodyStart = $matchStart + strlen($tableMatch);
// Walk forward to find the matching close paren of the table body.
$depth = 1;
$i = $bodyStart;
$len = strlen($createSql);
while ($i < $len && $depth > 0) {
$c = $createSql[$i];
if ($c === '(') {
$depth++;
} elseif ($c === ')') {
$depth--;
}
$i++;
}
if ($depth !== 0) {
// Unbalanced parens -> malformed; skip past this match and retry.
$offset = $bodyStart;
continue;
}
$body = substr($createSql, $bodyStart, $i - 1 - $bodyStart);
$columns = [];
// Split body on top-level commas only (depth-tracked).
$colDepth = 0;
$current = '';
$bodyLen = strlen($body);
for ($j = 0; $j < $bodyLen; $j++) {
$ch = $body[$j];
if ($ch === '(') {
$colDepth++;
$current .= $ch;
} elseif ($ch === ')') {
$colDepth--;
$current .= $ch;
} elseif ($ch === ',' && $colDepth === 0) {
$col = extract_column_name($current);
if ($col !== null) {
$columns[] = $col;
}
$current = '';
} else {
$current .= $ch;
}
}
$lastCol = extract_column_name($current);
if ($lastCol !== null) {
$columns[] = $lastCol;
}
$results[] = ['table' => $table, 'columns' => $columns];
// Resume scanning after this table's closing paren.
$offset = $i;
}
return $results;
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →