← All apps

CandyQuery

πŸ”Ž CandyQuery

Terminal SQLite browser

port of lazysql sql sqlite tui pdo

Composer-installable multi-driver SQL TUI: connect to SQLite, MySQL, or PostgreSQL, browse tables, peek at row data, run ad-hoc queries, and explore a full MySQL-Workbench-style admin dashboard β€” all on the SugarCraft stack. SQLite-first; MySQL and PostgreSQL drivers plus a Performance Dashboard, Server Status, Variables, Connections, Reports, and Performance Schema Setup ship as of v1.

Install

composer require sugarcraft/candy-query

Quickstart

# SQLite via bare path (inferred as Flavor::Sqlite)
bin/candy-query app.sqlite

# Any DSN β€” MySQL, PostgreSQL, SQLite, SQLSRV
bin/candy-query --dsn mysql://user:pass@localhost:3306/dbname

// Or wire it up programmatically:
use SugarCraft\Core\Program;
use SugarCraft\Core\ProgramOptions;
use SugarCraft\Query\App;
use SugarCraft\Query\Db\ConnectionFactory;

$pdo = ConnectionFactory::fromDsn('mysql://user:pass@localhost:3306/dbname');
(new Program(App::start($pdo), new ProgramOptions(useAltScreen: true)))->run();

What's in the box

Tables paneLists every type IN (table, view) in sqlite_master excluding sqlite_* internals.
Rows paneFirst 100 rows of the selected table, with header row + reverse-video cursor.
Query paneAd-hoc SQL editor β€” Ctrl+R runs, errors stash to a status line instead of crashing.
Tab / Backspace / SpacePane cycle, character delete, and space typing all respect the focused pane.
Cursor clampingLists never let the cursor escape their bounds; switching panes preserves per-pane positions.
PDO under the hood:memory: driving in tests covers the full transition surface without fixture files.
SchemaBrowserStrategy pattern for schema introspection β€” delegates to driver-specific SchemaProviderInterface (SqliteSchemaProvider, MysqlSchemaProvider, PostgresSchemaProvider) based on Flavor. Returns immutable SchemaTable/SchemaColumn/SchemaIndex/SchemaForeignKey value objects.
ResultPagerCursor-based pagination for SQL result sets. Immutable + fluent: nextPage(), prevPage(), goToPage(), withPageSize(). Default page size 25.
CellEditorCell-level UPDATE by primary-key identity. updateCell(), updateRow(), readCell() β€” all safely quoted, all return rows affected.
SnippetStoreFile-backed JSON store for named SQL snippets. load()/add()/flush()/find()/search()/delete() β€” immutable + fluent, persists to /tmp/candy-query-snippets.json.
Exporters (CsvExporter, SqlExporter)Driver-agnostic export to CSV and SQL INSERT. CsvExporter produces RFC-4180 CSV via fputcsv() with formula-injection guard (=+-@\t\r prefixed with ') on headers and cells. SqlExporter emits INSERT statements only (no CREATE TABLE β€” not driver-neutral). Both use SELECT * LIMIT 0/1 for column detection; exporting an empty table produces a blank file.
ConnectionConfigReadonly value object: driver, host, port, user, pass, dbname, sslMode, dsn. Password never echoed.
ConnectionFactoryStatic factory: fromDsn(), fromConfig(), fromArgv(). Builds configured PDO connections.
FlavorEnum: MySQL, MariaDB, Percona, Postgres, Sqlite. Identifies the database server flavor. Flavor::detectFromDriver() detects flavor from a PDO driver name + optional version string; Flavor::detectFromVersionString() refines MySQL to MariaDB/Percona using the version comment.
VersionParser for server version strings. Handles MariaDB's 5.5.5- prefix. isAtLeast(Version) compares versions safely.
ExplainViewStrategy pattern for EXPLAIN output β€” delegates to driver-specific ExplainProviderInterface (SqliteExplainProvider, MysqlExplainProvider, PostgresExplainProvider) based on Flavor. run() executes against a Database; render() produces ANSI coloured tree; toArray() returns JSON-serialisable structure.
ResultTableSQL result set renderer with horizontal scrolling, JSON pretty-print (2-space indent), styled NULL token, and column auto-sizing. scrollLeft()/scrollRight()/render()/renderPlain().
ConnectionsPageProcesslist browser with keyboard navigation (j/k/↑/↓), detail tab cycling (Tab/1/2/3), hide-sleeping filter (f), and async refresh (r) via Cmd::send(). Memoized cachedFilteredProcesslist avoids 2–3Γ— DB fetch per render. Mirrors charmbracelet/lazysql connections page.
ConnectionFiltersImmutable filter config: hide-sleeping, hide-background, skip-full-info, refresh-rate (0.5–30s). All fields are readonly; paired $Set sentinels for nullability.
ConnectionCountersConnection metrics from SHOW GLOBAL STATUS: threads-connected/running/cached, connections, aborted-connects, connection-errors. Computes connectionUsageRatio() lazily (0.0–1.0); isConnectionUsageCritical() returns true β‰₯80%.
ConnectionDetailTabsThree detail tabs (Details/Attributes/MDL) per processlist thread. Details from performance_schema.threads; Attributes from session_connect_attrs; MDL from performance_schema.metadata_locks with fallback to information_schema.metadata_lock_info. Returns null on permission errors (1142/1146/1227).
ReportsPagePerformance Reports admin page: left category/report tree + right sortable/exportable grid. validate() only loads Catalog (file I/O) β€” no DB queries on the render path. Navigation keys: h/l (prev/next category, wrap-around), [/] (prev/next report within category), ,/. (prev/next column index). All navigation triggers async report loading via loadCurrentReport(). selectedColumnIndex tracks focused column for future per-column unit cycling; [c] is currently a global unit toggle. Footer keybindings: [j/k] nav rows [h/l] category [/] report [c] unit toggle [q] quit.
ReportRunnerExecutes SELECT * FROM sys.<view> for performance report views. Uses prepared statements with backtick-quoted view names. run() applies time/byte unit formatting via UnitFormatter; runRaw() returns unformatted values. Availability gating via AvailabilityChecker::isViewAvailable() before executing.
AvailabilityCheckerChecks sys schema view availability via SHOW FULL TABLES FROM sys WHERE Table_type='VIEW'. Caches result in-memory after first fetch. discoverViews() catches \Throwable (not just \PDOException) because React/cached connections can surface non-PDO error types. isViewAvailable(), availableFromCatalog(), missingFromCatalog() for filtering.
Reports\CatalogLoads data/sys_reports.json and provides get(), all(), byCategory(), categories(). Categories are sorted by a curated CATEGORY_ORDER constant (problems first, matching MySQL Workbench Appendix B) rather than alphabetically; unknown categories fall through to alphabetical ordering. Column type parsing uses ColumnType::tryFrom() instead of ColumnType::from() to gracefully handle unknown type strings (returns null instead of throwing ValueError). Unknown types fall back to ColumnType::String.
VariablesPageDual-tab (Status/System) variable browser with category tree, search filtering, and inline edit. [e] opens a two-phase dialog: type the new value (DLG_INPUT), press [Enter] to preview the SET statement (DLG_CONFIRM), then [Enter] again to execute or [Esc] to cancel. Self-write guard: setting a variable to its current value is a no-op β€” the dialog stays in input phase. Static (non-dynamic) variables show error 1238 ("requires server restart") at the confirm phase rather than silently refusing at the entry point. All state transitions return new immutable instances. Keybindings: [j/k] nav, [w] rw filter, [s] search, [tab] toggle tab, [e] edit (dynamic vars only), [q] quit. Mirrors charmbracelet/lazysql VariablesPage.
VariableEditorInline editor for MySQL variables via SET GLOBAL / SET PERSIST / SET PERSIST_ONLY / RESET PERSIST (all MySQL 8.0+). Uses prepared statements with backtick-quoted variable names (from catalog) and ? placeholders for values β€” value interpolation is injection-safe. Press [p] in the edit dialog to cycle through GLOBAL β†’ PERSIST β†’ PERSIST_ONLY modes; error 1238 prompts to use PERSIST_ONLY. Error codes surfaced: 1142 (no privilege), 1227 (access denied), 1238 (variable is not dynamic), 3680 (persisted_variables restriction). Mirrors mysql-workbench wb_admin_variable_editor.
VariableMetadataImmutable descriptor for a single MySQL system variable: name, description, editable flag, dynamic flag, and group memberships. editable = can be set at all (SET GLOBAL / SET PERSIST); dynamic = runtime-changeable without restart (static vars like innodb_log_file_size accept SET GLOBAL but error 1238). Constructed from data/variable_metadata.json (1563 entries, 67 groups).
CatalogVariable metadata catalog loaded from data/variable_metadata.json (1563 variables, 67 groups). Provides get(), all(), byGroup(), groups(), isEditable(), isDynamic(). isDynamic() returns false for static variables (e.g. innodb_log_file_size) β€” used by VariablesPage to gate inline editing so static vars hit error 1238 rather than silently declining. VariableMetadata carries both editable (can be set at all) and dynamic (runtime-changeable without restart). Missing metadata is non-fatal β€” the VariablesPage renders without categories or [rw] indicators.
AdminProviderInterfaceFlavor-agnostic interface for admin operations: dashboard(), connections(), serverInfo(). Implemented by MysqlAdminProvider (MySQL) and PostgresAdminProvider (PostgreSQL via pg_stat_database, pg_settings, pg_stat_activity).
MysqlAdminProviderAdminProviderInterface via SHOW GLOBAL STATUS, SHOW GLOBAL VARIABLES, SHOW ENGINE INNODB STATUS, SHOW PROCESSLIST, and SHOW REPLICA STATUS (graceful 1227 handling).
PostgresAdminProviderAdminProviderInterface via pg_stat_database, pg_settings, pg_stat_activity. Dashboard/connections implemented via PostgresWidgetCatalog; serverInfo() maps pg_stat_database fields. Graceful degradation on permission errors (returns null/stub for inaccessible panels).
ServerStatusPage2-column admin page: left panel shows server info (host, socket, port, version, uptime + running-since), Features, Directories, SSL, Replication (SHOW REPLICA STATUS for MySQL 8+ / SHOW SLAVE STATUS for MySQL 5.x/MariaDB, graceful 1227 handling), and Firewall (AWS RDS); right panel shows SidebarGaugeSet with five sampled gauges (Connections, Traffic, Key Efficiency, QPS, InnoDB). r refreshes; q quits.
ServerStatusSnapshotAdapterAdapter wrapping ServerContextInterface to satisfy StatusSnapshotProviderInterface, enabling Sampler to compute per-second rate deltas across poll cycles for the Traffic gauge. Caches the last snapshot internally; currentSnapshot() delegates to $this->context->statusVariables(). Created in App::adminPage() alongside the Sampler and passed to ServerStatusPage::new().
SidebarGaugeSetCollection of 5 gauges: Connections (threads_connected/max_connections), Traffic (sampled per-second bytes rate via Sampler using Bytes_received/Bytes_sent deltas, baseline-corrected against 10MB/s), Key Efficiency (Key_reads / (Key_reads + Key_read_requests)), QPS (Questions/Uptime), and InnoDB Buffer Pool utilization. No CPU gauge β€” MySQL provides no CPU status variable; the prior gauge was a mislabeled connections ratio. Sampler advances on each withRefresh() call via poll(). r key triggers withRefresh() to rebuild the gauge set and advance sampler state.
ReconnectManagerDetects MySQL errors 2002/2003/2013 (connection lost). Stores ConnectionConfig, retries via attemptReconnect(), throws ReconnectException on failure.
StatementTimeoutWall-clock timeout on PDOStatement::execute() via pcntl_alarm(). Gracefully degrades when pcntl is unavailable. Cancels query via KILL CONNECTION_ID() on timeout, throws StatementTimeoutException.
PerfSchemaPagePerformance Schema configuration page with 7 tabs (Easy Setup, Instruments, Consumers, Actors, Objects, Threads, Options). Uses EasySetupDetector (wired via EasySetupDetector::fromContext($context)) for four-state detection: fully (all consumers enabled AND all instruments enabled+timed; TIMED-off β†’ custom), default (matches Appendix C profile), custom (user-modified), disabled (PS inaccessible). State shown as a colour-coded badge in the header (green/yellow/blue/red). Falls back to detectSetupState() (instrument data only, less accurate) when no detector is wired. Version gating: setup_actors skipped on <5.6; setup_objects.ENABLED column omitted on <5.6.3; timers from setup_timers (mutable) on <8.0 and performance_timers (read-only) on β‰₯8.0. Threads tab shows INSTRUMENTED status from performance_schema.threads. Instruments tab renders an indented tree with group nodes showing tri-state badges ([x]/[~]/[ ]). Read-only mode when PROCESS privilege is absent (tested via no-op UPDATE). Keybindings: j/k nav, Space/Enter toggle, Tab switch tabs, c commit, r revert, 1/2/3 Easy Setup, q quit. Mirrors mysql-workbench wb_admin_performance_schema.
EasySetupDetectorFour-state PS setup detector using COUNT/SUM queries against setup_instruments and setup_consumers. Returns fully, default, custom, or disabled. Guard: enabledPercentage() < 100 prevents fully even when detailed counts appear zero (ambiguous with limited data). isFullyDisabled() exists but is NOT wired to detect() β€” available for future use. Version-gated defaults (Appendix C): 5.6 vs 5.7 instruments are identical; 5.7 adds statements_digest to consumers. Excludes memory/% from all calculations. Graceful degradation on PDOException (privilege/access/connection errors). Factory: ::new(db, ?version), ::fromContext(context). Mirrors mysql-workbench wb_admin_performance_schema easy_setup_detector.
EasySetupPS easy-setup SQL toggle statements and Appendix C default sets. Provides enableStatements() (all instruments+consumers to YES/YES), disableStatements() (all to NO/NO), resetToDefaultStatements() (applies version-appropriate defaults). Default instruments (5.6/5.7): wait/io/file/%, wait/io/table/%, wait/lock/table/sql/handler, statement/%, idle. Default consumers (5.6): four; (5.7+): adds statements_digest. Version-aware β€” defaultInstruments()/defaultConsumers() return the correct set. memory/% excluded from all operations. Factory: ::new(?version), ::fromContext(context). Mirrors mysql-workbench wb_admin_performance_schema easy_setup.
SetupTimersMutable model for a PS timer entry. On MySQL <8.0, loaded from performance_schema.setup_timers and editable via withTimerName(); isDirty() tracks unsaved changes; commitStatements() generates UPDATE setup_timers SET TIMER_NAME=? WHERE NAME=?. On MySQL β‰₯8.0, loaded from performance_schema.performance_timers as clean (read-only) instances β€” commitStatements() returns []. Uses the Mutable trait for immutable-with* semantics.
SetupThreadsMutable model for a PS thread entry carrying the INSTRUMENTED flag from performance_schema.threads. withInstrumented(bool) returns a new instance with dirty tracking; isDirty() reports unsaved changes; instrumentedFragment() generates a THREAD_ID=N AND INSTRUMENTED='YES'/'NO' SQL fragment for CommitPlanner's batch UPDATE (STEP 5.3 wiring). Read-only by default (instrumented=true, dirty=false on initial load).
CommitPlannerGenerates SQL statements to commit PS configuration changes. All statements are fully parameterized β€” returns list<array{sql:string, params:list<mixed>}> tuples with all values bound as ? placeholders, none interpolated. Instruments use anchored RLIKE (^name$, regex-escaped via preg_quote()) bucketed by (enabled, timed); Consumers use UPDATE ... IN(?..) with bound names; Actors and Objects use keyed INSERT/UPDATE/DELETE with all values bound. isDirty() scans all tracked models. SetupTimers/SetupThreads commit wiring deferred to STEP 5.3.
InstrumentTreeHierarchical view of PS instruments organized by name path (e.g. wait/io/file/sql/binlog β†’ 5-level tree). Tracks aggregate enabled/timed state of each subtree via tri-state values: 1=all-enabled, 0=mixed, -1=all-disabled. Provides setChildrenEnabled(bool) / setChildrenTimed(bool) cascade methods that recursively mark all instruments in a subtree with the same state and return the modified list. pathDepth() returns the number of path segments (root=0, wait=1, wait/io=2) for indent calculation. flattenTree() (in PerfSchemaPage) emits [nodeOrInstrument, depth, isGroup] triples for renderer consumption. Mirrors mysql-workbench wb_admin_performance_schema instrument_tree.
Restart detection + SamplerSampler records uptime at construction via registerUptime() and compares uptime snapshots across polls to detect MySQL restarts, resetting stale cached state before it becomes stale. StatusPoller uses this to guard against stale data after a server restart. Additionally, Sampler computes per-second rate deltas (Bytes_received, Bytes_sent, etc.) from two status snapshots, used by SidebarGaugeSet for baseline-corrected Traffic gauge ratios. Advance via poll() on each refresh cycle.
AlertingStateless AlertManager evaluates metrics against AlertThresholds presets (::default(), ::strict()). Alert value objects carry severity, metric, value, and threshold. AlertNotifier dispatches toasts via sugar-toast with mute-safe degradation when no factory is available. Maps Severity::Critical β†’ ToastType::Error, Warning β†’ Warning, Info β†’ Info.
Query History (optional)Opt-in SQLite persistence via SqliteHistoryStore (WAL mode, auto-pruned at 1000 entries). HistoryRecorder implements StatusSnapshotProviderInterface for passive recording in the polling loop. HistoryQuery computes queriesPerSecond(), averageDuration(), errorRate(), and topQueries() over time ranges.

Source & demos

Try the quickstart β†’

API

ClassMethodDescription
Database βš οΈβ€”Deprecated thin alias to SqliteDatabase. Use DatabaseInterface instead.
MysqlDatabaseβ€”DatabaseInterface implementation via PDO mysql. Implements serverVersion(), driverName(), ping(), databases().
PostgresDatabaseβ€”DatabaseInterface implementation via PDO pgsql. Implements serverVersion(), driverName(), ping(), databases().
DatabaseInterfaceserverVersion()Get database server version
DatabaseInterfacedriverName()Get driver name (e.g., sqlite, mysql)
DatabaseInterfaceping()Check connection is alive
DatabaseInterfacedatabases()List available databases
DatabaseInterfacetables()List all tables
DatabaseInterfacerows(table, limit, offset)Get table rows
DatabaseInterfacequery(sql)Execute SQL query; returns list<array>|null β€” null signals a reconnectable connection error (2002/2003/2013), empty array means no rows
DatabaseInterfaceprepare(sql)Prepare SQL; returns PreparedStatementInterface|null β€” driver-neutral statement wrapper
PreparedStatementInterfaceexecute(params?)Execute with bound params; returns bool
PreparedStatementInterfacefetch()Fetch next row; returns array<string,mixed>|false
PreparedStatementInterfacefetchAll()Fetch all rows; returns list<array<string,mixed>>
PreparedStatementInterfacerowCount()Rows affected by last query
PreparedStatementInterfacecloseCursor()Close cursor, enabling re-execution
PdoPreparedStatementβ€”PreparedStatementInterface via PDOStatement. All database implementations (MysqlDatabase, PostgresDatabase, SqliteDatabase) wrap their PDOStatement in this before returning from prepare().
Appstart(database, ?Flavor $flavor = Flavor::Sqlite)Create the app model
SchemaBrowserrefresh()Load schema via driver-specific SchemaProviderInterface
SchemaBrowserforFlavor(flavor, pdo)Create browser with explicit Flavor (static factory)
SchemaProviderInterfacetables(pdo)List all tables/views
SchemaProviderInterfacecolumns(pdo, table)Get columns for a table
SchemaProviderInterfaceindexes(pdo, table)Get indexes for a table
SchemaProviderInterfaceforeignKeys(pdo, table)Get foreign keys for a table
SqliteSchemaProviderβ€”SchemaProviderInterface via PRAGMA queries
MysqlSchemaProviderβ€”SchemaProviderInterface via INFORMATION_SCHEMA
PostgresSchemaProviderβ€”SchemaProviderInterface via pg_catalog
SchemaBrowserdropTable(name)DROP table and re-refresh
SchemaTablecolumn(name)Find a column by name
ResultPagerpage()Rows on current page
ResultPagernextPage() / prevPage() / goToPage(n)Navigate pages (all return new instance)
ResultPagerwithPageSize(n)Change page size (returns new instance)
ResultPagerhasNextPage() / hasPrevPage()Check navigation availability
CellEditorupdateCell(rowId, column, newValue)Update one cell, return rows affected
CellEditorupdateRow(rowId, cells)Update multiple cells in one row
CellEditorreadCell(rowId, column)Read current cell value
SnippetStoreload(path)Load snippets from JSON file (default /tmp/candy-query-snippets.json)
SnippetStoreadd(name, sql, description)Add or replace a snippet, return new store
SnippetStoreflush()Persist current snippets to disk
SnippetStorefind(name)Look up snippet by exact name
SnippetStoresearch(term)Case-insensitive substring search on name and SQL
SnippetStoredelete(name)Remove snippet by name, return new store
ExplainViewrun(pdo, sql)Execute EXPLAIN via driver-specific ExplainProviderInterface
ExplainViewforFlavor(flavor, pdo)Create view with explicit Flavor (static factory)
ExplainProviderInterfaceexplain(pdo, sql)Execute EXPLAIN and return explain rows
SqliteExplainProviderβ€”ExplainProviderInterface via EXPLAIN QUERY PLAN
MysqlExplainProviderβ€”ExplainProviderInterface via EXPLAIN
PostgresExplainProviderβ€”ExplainProviderInterface via EXPLAIN (ANALYZE, FORMAT JSON)
AdminProviderInterfacedashboard()Return admin dashboard data (server info, features)
AdminProviderInterfaceconnections()Return active connections list
AdminProviderInterfaceserverInfo()Return server information (version, uptime, config)
MysqlAdminProviderβ€”AdminProviderInterface via SHOW GLOBAL STATUS/VARIABLES, SHOW ENGINE INNODB STATUS, SHOW PROCESSLIST, SHOW REPLICA STATUS
ConnectionsPagenew(context, maxConnections?)Factory creating fully-wired instance with ProcesslistProvider, ConnectionActions, ConnectionDetailTabs, ConnectionCounters
ConnectionsPageupdate(Msg)Handle keyboard: j/k/↑/↓ navigate, Tab/1/2/3 cycle tabs, f toggle filter, r refresh. Returns [newPage, ?Closure]. Mirrors charmbracelet/lazysql.
ConnectionsPagegetTable()Get the sugar-table Table with columns: Id, User, Host, DB, Command, Time, State, Info
ConnectionsPagefilters() / counters() / detailTab() / selectedThread()Read current filter state, counters, active detail tab, and selected process thread
ConnectionFiltersnew()Default filters: no hiding, no auto-refresh
ConnectionFilterswithHideSleeping(bool) / withHideBackground(bool) / withSkipFullInfo(bool) / withRefreshRate(float|null)Fluent filter setters β€” all return new immutable instance
ConnectionCountersfromSnapshot(snapshot, maxConnections)Factory from StatusSnapshot: reads Threads_*, Connections, Aborted_* variables
ConnectionCountersconnectionUsageRatio()Lazy-computed ratio (0.0–1.0) of threads_connected / max_connections
ConnectionCountersisConnectionUsageCritical()True when usage β‰₯80% (red threshold)
ConnectionDetailTabsgetDetails(processId)Basic thread info from performance_schema.threads β€” returns null on 1142/1146/1227
ConnectionDetailTabsgetAttributes(processId)Session connect attrs from performance_schema.session_connect_attrs β€” returns null if no attrs or access denied
ConnectionDetailTabsgetMdlLocks(processId)Metadata locks from performance_schema.metadata_locks (primary) with fallback to information_schema.metadata_lock_info
ConnectionDetailTabsgetThreadStack(processId)Stack dump via sys.ps_thread_stack() β€” returns null if sys schema unavailable
ConnectionDetailTabsgetExplain(processId)Runs EXPLAIN on the selected thread's current query β€” returns null if no query or explain fails
PostgresAdminProviderβ€”AdminProviderInterface via pg_stat_database, pg_settings, pg_stat_activity. Dashboard/connections via PostgresWidgetCatalog; graceful null on permission errors (1142/1146).
ReportsPagenew(context, ?db)Factory. validate() only loads Catalog (file I/O) β€” no blocking db->query() on render path. report queries routed through AdminQueryCache async.
ReportsPageupdate(Msg)Handles ReloadReportMsg (triggers loadCurrentReport), j/k navigate, r refresh, x export, c unit toggle, q quit.
ReportsPageexportToCsv()Export current report as RFC-4180 CSV string via CsvExporter::exportReportResultsToString(). Returns '' if no report loaded.
ReportRunnerrun(viewName, ?limit)Execute SELECT * FROM sys.<view> with unit formatting. Throws \InvalidArgumentException if view not in catalog, \RuntimeException if view not available on server.
ReportRunnerrunRaw(viewName, ?limit)Execute report without unit formatting β€” returns raw numeric values for sorting/calculation.
ReportRunnercanRun(viewName)True if report exists in catalog and its view is available on this server.
AvailabilityCheckerdiscoverViews()Runs SHOW FULL TABLES FROM sys WHERE Table_type='VIEW'. Caches result. Catches \Throwable (not just \PDOException) because React/cached connections can surface non-PDO errors.
AvailabilityCheckersysSchemaExists()True if sys schema is accessible (runs discoverViews() internally).
AvailabilityCheckerisViewAvailable(viewName)True if the named sys schema view exists on this server.
AvailabilityCheckeravailableFromCatalog(catalog)Return catalog reports whose views exist on this server.
AvailabilityCheckermissingFromCatalog(catalog)Return catalog reports whose views are missing on this server.
AvailabilityCheckerreset()Clear cached view list β€” force re-discovery on next call.
ReloadReportMsgβ€”Dispatched by App after AdminDataLoadedMsg. ReportsPage handles this in update() to queue the report query via AdminQueryCache for the next tick.
VariablesPagenew(context, ?catalog, ?editor)Factory. Optional Catalog (eagerly loaded variable metadata) and VariableEditor enable category tree, [rw] indicators, and inline editing. Missing catalog/editor is non-fatal β€” page renders without those features.
VariablesPageupdate(Msg)Handles keyboard: j/k navigate rows, w toggle rw filter, s focus search, tab toggle Status/System, e open edit dialog (dynamic vars only), q quit. When edit dialog is active, delegates to updateDialog(). Returns [newPage, ?Closure]. All with*() methods return new immutable instances.
VariableEditoredit(variableName, newValue)Execute SET GLOBAL via prepared statement. Backtick-escapes variable name (from catalog), uses ? placeholder for value. Returns ['success' => bool, 'errorCode' => ?int, 'errorMessage' => ?string]. Error 1238 = variable not dynamic; 1142/1227 = privilege; 3680 = persisted_variables restriction.
VariableEditorpersist(variableName, newValue)Execute SET PERSIST (MySQL 8.0+) β€” sets the variable globally and writes to mysqld-auto.cnf. Version-gated. Uses prepared statement with backtick-quoted name.
VariableEditorpersistOnly(variableName, newValue)Execute SET PERSIST_ONLY (MySQL 8.0+) β€” writes to mysqld-auto.cnf without affecting the running value. Use for static variables (error 1238). Version-gated.
VariableEditorresetPersist(?variableName)Execute RESET PERSIST [x] (MySQL 8.0+). Without argument clears all persisted vars. With argument removes only that variable. No isEditable() check (it removes, not sets). Version-gated.
VariableEditorgetEditPreview(variableName, newValue, mode='global')Return a preview string of the SET statement that would be executed. mode is 'global'|'persist'|'persist_only' (e.g. SET PERSIST `max_connections` = '200').
VariableEditorisEditable(variableName)True if the variable has editable=true in the metadata catalog. Returns false when catalog is absent (safer default).
VariableMetadataisDynamic()True if the variable has Dynamic=true in metadata (can be changed at runtime via SET GLOBAL). Non-dynamic variables hit MySQL error 1238.
VariableMetadatainGroup(group)True if the variable belongs to the given group name.
Catalogload()Load variable metadata from data/variable_metadata.json. Throws \RuntimeException on corrupt/missing file.
Catalogget(variableName)Return VariableMetadata for a variable, or null if not in catalog.
Catalogall()Return all VariableMetadata entries keyed by variable name.
Cataloggroups()Return sorted list of all group names in the catalog.
CatalogisEditable(variableName)True if variable exists in catalog and VariableMetadata.editable is true.
CatalogisDynamic(variableName)True if variable exists in catalog and VariableMetadata.isDynamic() is true.
ExplainViewrender()Render plan as ANSI-coloured string
ExplainViewtoArray()Return JSON-serialisable plan rows (depth, tag, detail, indent)
ConnectionActionssetInstrumentation(bool $enabled, int|string $threadId)Enable/disable performance_schema instrumentation per-thread via UPDATE performance_schema.threads SET INSTRUMENTED=?. Requires MySQL 8.0+; returns false on permission errors.
ConnectionActionskill(int|string $threadId, bool $isBackground)KILL a thread (disconnect). Refuses to kill background threads (no user or NULL user). MySQL's KILL does not accept placeholders; ID is int-cast directly (injection-safe). Returns true on success, false on refusal or error.
ConnectionActionskillQuery(int|string $threadId, bool $isBackground)KILL QUERY a thread (cancel statement, keep connection). Refuses to kill query on background threads. MySQL's KILL QUERY does not accept placeholders; ID is int-cast directly (injection-safe). Returns true on success, false on refusal or error.
ConnectionDetailTabsgetExplain(processId)Runs EXPLAIN on the selected thread's current query. Accepts single SELECT statements only β€” rejects multi-statement queries (containing ;) and any non-SELECT statement (INSERT/UPDATE/DELETE/DROP/etc.) before running. Returns null if no query, explain fails, or query is not a single SELECT.
ConnectionConfigβ€”Readonly value object: driver, host, port, user, pass, dbname, sslMode, dsn
ConnectionFactoryfromDsn(dsn)Parse DSN string via parse_url() (mysql/pgsql) or regex (sqlite) and build a ConnectionConfig. Handles URL-encoded passwords, passwordless users, and IPv6 hosts. The ?ssl-mode= query param is parsed and stored in ConnectionConfig.sslMode; SSL is applied as PDO driver options in MysqlDatabase::connect(), not embedded in the DSN string. Examples: mysql://u:p%40ss@[::1]:3306/db, mysql://root@localhost/db.
ConnectionFactoryfromConfig(config)Build PDO from ConnectionConfig value object
ConnectionFactoryfromArgv()Build PDO from CLI args (--db-driver, --db-host, --db-port, --db-user, --db-pass, --db-name, --db-ssl-mode)
FlavordetectFromDriver(driver, version?, versionComment?)Detect flavor from PDO driver name ('mysql', 'pgsql', 'sqlite') + optional version string. Uses detectFromVersionString() for MySQL refinement. Ensures mysql/pgsql never falls back to SQLite for an unparseable version.
VersionisAtLeast(Version)Compare version strings (handles MariaDB 5.5.5- prefix)
ResultTablefromRows(rows)Factory from a list of associative row arrays
ResultTablescrollLeft() / scrollRight()Scroll viewport by one column
ResultTablewithVisibleWidth(n) / withJsonPretty(bool) / withNullToken(s)Fluent configuration builders
ResultTablerender() / renderPlain()Render ANSI string or plain text
CsvExporterexportCsv(path, table)Export a table to a CSV file (RFC-4180, formula guard on headers+values, driver-neutral column detection via LIMIT 0/LIMIT 1)
CsvExporterexportReportResults(path, columns, rows)Write a result set to a CSV file with formula guard
CsvExporterexportReportResultsToString(columns, rows)Return a result set as an RFC-4180 CSV string (used by ReportsPage [x] export)
CsvExporterimportCsv(path, table)Import a CSV file into a table (backtick-quoted columns, safe VALUES)
SqlExporterexportSql(path)Dump all tables as INSERT statements to a SQL file. No CREATE TABLE (not driver-neutral). Uses LIMIT 1 for column detection.
ReconnectManagershouldReconnect(pdoException)Check if error code is 2002/2003/2013
ReconnectManagerattemptReconnect(callable)Retry connection using stored ConnectionConfig
ReconnectManagersetConnectionConfig(config) / lastConnectionConfig()Store/retrieve connection config for reconnection
ReconnectExceptionβ€”Thrown when reconnection fails after MySQL connection error
StatementTimeoutexecute(stmt, values)Execute with wall-clock timeout via pcntl_alarm()
StatementTimeoutdidTimeout()Check if last execution timed out
StatementTimeoutExceptionβ€”Thrown when statement exceeds timeout and is cancelled
SeveritytoToastType()Map to ToastType: Info→Info, Warning→Warning, Critical→Error
Alertwarning() / critical() / info()Factory helpers for alert creation
AlertisCritical() / isWarning() / isInfo()Severity predicates
AlerttoToastMessage()Format as "metric: message (X% > Y%)"
AlertThresholdsnew() / default() / strict()Factory presets: bare, 60%/80%, 50%/70%
AlertThresholdsconnectionWarningThreshold() / connectionCriticalThreshold()Connection usage ratio thresholds
AlertThresholdswithConnectionWarningThreshold() / withConnectionCriticalThreshold()Fluent threshold setters (0.0–1.0)
AlertThresholdsabortedRateThreshold() / withAbortedRateThreshold()Aborted connection rate threshold
AlertThresholdsslowQueryThreshold() / withSlowQueryThreshold()Slow query time threshold in seconds
AlertThresholdsthreadRunningThreshold() / withThreadRunningThreshold()Thread running ratio threshold
AlertThresholdsconnectionErrorsThreshold() / withConnectionErrorsThreshold()Absolute connection error count threshold
AlertThresholdswatchedMetrics() / withWatchedMetrics() / watches()Filter which metrics are actively watched
AlertThresholdstoastEnabled() / withToastEnabled() / toastPosition() / toastDuration()Toast notification settings
AlertNotifiernew() / withDefaults()Factory (muted by default when no factory provided)
AlertNotifiernotify(Alert) / notifyWarning() / notifyCritical() / notifyInfo()Send notifications (all return new instance)
AlertNotifierwithMuted() / isMuted()Mute state control
AlertNotifierwithToastFactory()Late-bind toast factory
AlertNotifierview(background, width, height)Compose toast over viewport string
AlertNotifiertoast() / hasActiveAlert()Direct toast access and state query
AlertManagernew()Fresh manager with default thresholds
AlertManagerwithThresholds() / withNotifier()Attach threshold config and notifier
AlertManagercheckConnectionUsage(ConnectionCounters)Evaluate connection counters, return array<string, Alert>
AlertManagercheckAllMetrics(statusVars, serverVars)Evaluate SHOW GLOBAL STATUS/VARIABLES, return array<string, Alert>
AlertManagercheckAndDispatch(ConnectionCounters)Combined check + notify, returns ['alerts', 'notifier']
HistoryStoreInterfacesave(entry)Persist a history entry (query, duration_ms, rows_affected, error, ts)
HistoryStoreInterfacequery(from, to, limit)Retrieve history entries within time range
HistoryStoreInterfaceprune(before)Delete entries older than given DateTimeImmutable
HistoryStoreInterfacecount()Return total number of stored entries
SqliteHistoryStoreopen(path)Open or create SQLite file with WAL mode
SqliteHistoryStoreβ€”HistoryStoreInterface via SQLite. Schema: id, query, duration_ms, rows_affected, error, ts. Auto-prunes at 1000 entries on save().
HistoryRecorderprovideStatusSnapshot(previous)Implements StatusSnapshotProviderInterface. Calls store->save() when invoked by polling loop.
HistoryQueryqueriesPerSecond(from, to)Compute QPS over a time range
HistoryQueryaverageDuration(from, to)Compute mean execution time in ms over a time range
HistoryQueryerrorRate(from, to)Compute fraction of queries that resulted in error
HistoryQuerytopQueries(limit)Return most-frequent queries with counts
PerfSchemaPagenew(context, ?detector, ?commitPlanner)Factory. Validates PS accessibility via SELECT COUNT(*) FROM performance_schema.setup_instruments. 7 tabs: Easy Setup, Instruments, Consumers, Actors, Objects, Threads, Options.
PerfSchemaPageupdate(Msg)Handles keyboard: j/k nav rows, Space/Enter toggle, Tab/Shift+Tab switch tabs, c commit (when dirty), r revert (when dirty), 1/2/3 Easy Setup, q quit. Returns [newPage, ?Closure].
PerfSchemaPageinstruments() / consumers() / actors() / objects() / threads() / timers()Read current tab's model list.
PerfSchemaPageactiveTab() / selectedRowIndex() / isReadOnly() / setupState()Read page state: active tab name, cursor index, privilege status, detected PS setup state (fully/default/custom/disabled).
SetupTimersnew(name, timerName)Factory.
SetupTimerswithTimerName(timerName)Return new instance with updated timer. Marks dirty and sets changeType to CHANGE_UPDATE. No-op when timer name is unchanged.
SetupTimersisDirty()True if timer has been modified since load.
SetupTimersgetChangeType()Returns CHANGE_UPDATE or CHANGE_NONE.
SetupTimerscommitStatements()Returns ['UPDATE performance_schema.setup_timers SET TIMER_NAME='NANOSECOND' WHERE NAME='wait''] when dirty and changeType is CHANGE_UPDATE. Returns [] otherwise (including on MySQL β‰₯8.0 where instances are clean from performance_timers).
SetupTimersnameUpper() / isCycle()Helpers: timer name in uppercase, true if CYCLE timer.
SetupThreadsnew(threadId, name, type, ?processlistId, ?processlistUser, ?processlistCommand, ?processlistInfo, instrumented=true)Factory. instrumented defaults to true.
SetupThreadswithInstrumented(bool)Return new instance with updated INSTRUMENTED flag. Marks dirty. No-op when state is unchanged.
SetupThreadsisDirty()True if thread's INSTRUMENTED state has been modified.
SetupThreadsisForeground() / isBackground() / hasProcesslist()Type predicates.
SetupThreadsinstrumentedFragment()Returns SQL fragment: THREAD_ID = 123 AND INSTRUMENTED = 'YES' (or 'NO'). Used by CommitPlanner to build a batch UPDATE ... WHERE IN() clause.
SetupThreadscommitStatements()Returns [] β€” individual threads delegate to CommitPlanner for the batch IN() update. Stub for interface compatibility.
CommitPlannernew(instruments, consumers, actors, objects)Factory. Takes arrays of dirty-tracking model instances.
CommitPlannercommitAll()Generate all SQL statements for dirty instruments (anchored RLIKE), consumers (IN), actors (INSERT/UPDATE/DELETE), and objects (INSERT/UPDATE/DELETE). Returns list<array{sql:string, params:list<mixed>}> β€” fully parameterized with ? placeholders and bound values, no string interpolation. Returns [] when nothing is dirty. SetupTimers/SetupThreads wiring deferred to STEP 5.3.
CommitPlannercommitInstruments()Group dirty instruments by (enabled, timed) bucket; emit one UPDATE ... WHERE NAME RLIKE '^...$' per bucket. Pattern is anchored (^name$) and regex-escaped via preg_quote(); metacharacters like . / ( ) are matched literally. Returns list<array{sql:string, params:list<mixed>}>.
CommitPlannercommitConsumers()Group dirty consumers by enabled state; emit one UPDATE ... WHERE NAME IN (?,?,...) per group. All names bound as ? placeholders. Returns list<array{sql:string, params:list<mixed>}>.
CommitPlannercommitActors()Emit parameterized INSERT/UPDATE/DELETE for actors based on getChangeType(). All string values (HOST, USER, ROLE, ENABLED) bound as ?. Returns list<array{sql:string, params:list<mixed>}>.
CommitPlannercommitObjects()Emit parameterized INSERT/UPDATE/DELETE for objects based on getChangeType(). All string values (OBJECT_TYPE, OBJECT_SCHEMA, OBJECT_NAME, ENABLED, TIMED) bound as ?. Returns list<array{sql:string, params:list<mixed>}>.
CommitPlannerisDirty()True if any tracked model is dirty.

Smoke Testing.

Manual verification checklist β€” CI runs unit tests against fakes; these steps validate end-to-end behavior against real database servers.

SQLite smoke (browser regression)

No live server needed. Tests the TUI layer and async cache with an in-memory SQLite connection.

  1. php bin/candy-query --dsn sqlite:///tmp/smoke_test.db β€” launches; table list populates within 1 second.
  2. Press Tab to cycle panes β€” Schema Browser, Query Editor, History, Results all render without crash.
  3. Press n in Schema Browser β€” new table dialog opens; confirm/cancel works.
  4. Press e in table list β€” rows load with spinner; second tick shows rows (async cache).
  5. Press r in Query Editor β€” run a SELECT 1; result grid renders correctly.
  6. Press x in Results pane β€” CSV export produces valid RFC-4180 output (no trailing space padding, formula guard active).
  7. Press q β€” clean exit; no segfault or panic.

MySQL smoke (admin pages + round-trips)

Requires a scratch MySQL 5.6+ instance. Validates all 7 admin panes, version gating, and write round-trips.

  1. php bin/candy-query --dsn mysql://user:pass@localhost:3306/testdb β€” connects; status bar shows MySQL version.
  2. Admin pages reachable via number keys (1–7):
    • 1 Server Status β€” sidebar gauges (Connections, Traffic, Key Efficiency, QPS, InnoDB) render with live rate calculations; restart detection badge works.
    • 2 Process List β€” PS path active when performance_schema = ON; KILL button kills a test connection cleanly; KILL QUERY aborts a running query.
    • 3 Connections β€” connection list loads; ConnectionActions (kill, killQuery, setInstrumentation) all respond.
    • 4 Variables β€” [e] opens edit dialog for dynamic vars; [p] cycles GLOBAL/PERSIST/PERSIST_ONLY modes; error 1238 surfaces for static vars; SET PERSIST_ONLY commits successfully on MySQL 8+.
    • 5 Performance Schema β€” Easy Setup badge shows (fully/default/custom/disabled); all 7 tabs navigate; [c] commits dirty changes; on MySQL 5.5, actors tab is empty (version gate).
    • 6 Reports β€” category tree loads from catalog; [h]/[l] cycle categories; [ ] cycle reports; report executes and renders with unit toggle [c]; [x] exports CSV.
    • 7 Dashboard β€” widgets render with real values; alert toasts fire once on breach entry (not every tick); history recorder responds to poll loop.
  3. Variables edit round-trip: 4 β†’ pick wait_timeout β†’ [e] β†’ change value β†’ [Enter] confirm β†’ verify variable updated via SHOW GLOBAL VARIABLES LIKE 'wait_timeout'.
  4. PS instrument commit round-trip: 5 β†’ Instruments tab β†’ Space toggles a row β†’ [c] commits β†’ verify via SELECT * FROM performance_schema.setup_instruments WHERE NAME = '...'.
  5. CSV export round-trip: 6 β†’ navigate to a report β†’ [x] β†’ save to /tmp/export.csv β†’ verify file is valid RFC-4180 CSV (no trailing spaces, formula cells prefixed with ').
  6. Replica panel (MySQL 5.7.6+): connect to a replica β†’ 1 β†’ Server Status β†’ replica card shows configured/not-configured state; [g] opens GTID mode selector; [c] cycles modes; Enter commits SET @@GLOBAL.GTID_MODE = ....

PostgreSQL smoke

Requires a scratch PostgreSQL 10+ instance. Validates PostgresWidgetCatalog and pg_stat_database aggregation.

  1. php bin/candy-query --dsn pgsql://user:pass@localhost:5432/testdb β€” connects; status bar shows PostgreSQL version.
  2. Widgets show pg_stat_database-backed values (not empty/not stub).
  3. shared_buffers shows byte value (scaled from 8KB block count Γ— 8192).

Known limitations (documented, not fixed in this audit)

Demos.

VHS-recorded GIFs of every example shipped with the app. Regenerated automatically on every push that touches the source.

Play

Play

Browse tables, load rows, run an ad-hoc query.
Query history

Query history

↑↓ cycles through history, Ctrl+F favorites a query.