Terminal SQLite browser
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.
composer require sugarcraft/candy-query
# 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();
type IN (table, view) in sqlite_master excluding sqlite_* internals.:memory: driving in tests covers the full transition surface without fixture files.SchemaProviderInterface (SqliteSchemaProvider, MysqlSchemaProvider, PostgresSchemaProvider) based on Flavor. Returns immutable SchemaTable/SchemaColumn/SchemaIndex/SchemaForeignKey value objects.nextPage(), prevPage(), goToPage(), withPageSize(). Default page size 25.updateCell(), updateRow(), readCell() β all safely quoted, all return rows affected.load()/add()/flush()/find()/search()/delete() β immutable + fluent, persists to /tmp/candy-query-snippets.json.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.fromDsn(), fromConfig(), fromArgv(). Builds configured PDO connections.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.5.5.5- prefix. isAtLeast(Version) compares versions safely.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.scrollLeft()/scrollRight()/render()/renderPlain().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.$Set sentinels for nullability.SHOW GLOBAL STATUS: threads-connected/running/cached, connections, aborted-connects, connection-errors. Computes connectionUsageRatio() lazily (0.0β1.0); isConnectionUsageCritical() returns true β₯80%.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).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.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.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.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.[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.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.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).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.dashboard(), connections(), serverInfo(). Implemented by MysqlAdminProvider (MySQL) and PostgresAdminProvider (PostgreSQL via pg_stat_database, pg_settings, pg_stat_activity).AdminProviderInterface via SHOW GLOBAL STATUS, SHOW GLOBAL VARIABLES, SHOW ENGINE INNODB STATUS, SHOW PROCESSLIST, and SHOW REPLICA STATUS (graceful 1227 handling).AdminProviderInterface 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).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.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().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.ConnectionConfig, retries via attemptReconnect(), throws ReconnectException on failure.PDOStatement::execute() via pcntl_alarm(). Gracefully degrades when pcntl is unavailable. Cancels query via KILL CONNECTION_ID() on timeout, throws StatementTimeoutException.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.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.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.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.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).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.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.Sampler 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.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.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.| Class | Method | Description |
|---|---|---|
| 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(). |
| DatabaseInterface | serverVersion() | Get database server version |
| DatabaseInterface | driverName() | Get driver name (e.g., sqlite, mysql) |
| DatabaseInterface | ping() | Check connection is alive |
| DatabaseInterface | databases() | List available databases |
| DatabaseInterface | tables() | List all tables |
| DatabaseInterface | rows(table, limit, offset) | Get table rows |
| DatabaseInterface | query(sql) | Execute SQL query; returns list<array>|null β null signals a reconnectable connection error (2002/2003/2013), empty array means no rows |
| DatabaseInterface | prepare(sql) | Prepare SQL; returns PreparedStatementInterface|null β driver-neutral statement wrapper |
| PreparedStatementInterface | execute(params?) | Execute with bound params; returns bool |
| PreparedStatementInterface | fetch() | Fetch next row; returns array<string,mixed>|false |
| PreparedStatementInterface | fetchAll() | Fetch all rows; returns list<array<string,mixed>> |
| PreparedStatementInterface | rowCount() | Rows affected by last query |
| PreparedStatementInterface | closeCursor() | Close cursor, enabling re-execution |
| PdoPreparedStatement | β | PreparedStatementInterface via PDOStatement. All database implementations (MysqlDatabase, PostgresDatabase, SqliteDatabase) wrap their PDOStatement in this before returning from prepare(). |
| App | start(database, ?Flavor $flavor = Flavor::Sqlite) | Create the app model |
| SchemaBrowser | refresh() | Load schema via driver-specific SchemaProviderInterface |
| SchemaBrowser | forFlavor(flavor, pdo) | Create browser with explicit Flavor (static factory) |
| SchemaProviderInterface | tables(pdo) | List all tables/views |
| SchemaProviderInterface | columns(pdo, table) | Get columns for a table |
| SchemaProviderInterface | indexes(pdo, table) | Get indexes for a table |
| SchemaProviderInterface | foreignKeys(pdo, table) | Get foreign keys for a table |
| SqliteSchemaProvider | β | SchemaProviderInterface via PRAGMA queries |
| MysqlSchemaProvider | β | SchemaProviderInterface via INFORMATION_SCHEMA |
| PostgresSchemaProvider | β | SchemaProviderInterface via pg_catalog |
| SchemaBrowser | dropTable(name) | DROP table and re-refresh |
| SchemaTable | column(name) | Find a column by name |
| ResultPager | page() | Rows on current page |
| ResultPager | nextPage() / prevPage() / goToPage(n) | Navigate pages (all return new instance) |
| ResultPager | withPageSize(n) | Change page size (returns new instance) |
| ResultPager | hasNextPage() / hasPrevPage() | Check navigation availability |
| CellEditor | updateCell(rowId, column, newValue) | Update one cell, return rows affected |
| CellEditor | updateRow(rowId, cells) | Update multiple cells in one row |
| CellEditor | readCell(rowId, column) | Read current cell value |
| SnippetStore | load(path) | Load snippets from JSON file (default /tmp/candy-query-snippets.json) |
| SnippetStore | add(name, sql, description) | Add or replace a snippet, return new store |
| SnippetStore | flush() | Persist current snippets to disk |
| SnippetStore | find(name) | Look up snippet by exact name |
| SnippetStore | search(term) | Case-insensitive substring search on name and SQL |
| SnippetStore | delete(name) | Remove snippet by name, return new store |
| ExplainView | run(pdo, sql) | Execute EXPLAIN via driver-specific ExplainProviderInterface |
| ExplainView | forFlavor(flavor, pdo) | Create view with explicit Flavor (static factory) |
| ExplainProviderInterface | explain(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) |
| AdminProviderInterface | dashboard() | Return admin dashboard data (server info, features) |
| AdminProviderInterface | connections() | Return active connections list |
| AdminProviderInterface | serverInfo() | Return server information (version, uptime, config) |
| MysqlAdminProvider | β | AdminProviderInterface via SHOW GLOBAL STATUS/VARIABLES, SHOW ENGINE INNODB STATUS, SHOW PROCESSLIST, SHOW REPLICA STATUS |
| ConnectionsPage | new(context, maxConnections?) | Factory creating fully-wired instance with ProcesslistProvider, ConnectionActions, ConnectionDetailTabs, ConnectionCounters |
| ConnectionsPage | update(Msg) | Handle keyboard: j/k/β/β navigate, Tab/1/2/3 cycle tabs, f toggle filter, r refresh. Returns [newPage, ?Closure]. Mirrors charmbracelet/lazysql. |
| ConnectionsPage | getTable() | Get the sugar-table Table with columns: Id, User, Host, DB, Command, Time, State, Info |
| ConnectionsPage | filters() / counters() / detailTab() / selectedThread() | Read current filter state, counters, active detail tab, and selected process thread |
| ConnectionFilters | new() | Default filters: no hiding, no auto-refresh |
| ConnectionFilters | withHideSleeping(bool) / withHideBackground(bool) / withSkipFullInfo(bool) / withRefreshRate(float|null) | Fluent filter setters β all return new immutable instance |
| ConnectionCounters | fromSnapshot(snapshot, maxConnections) | Factory from StatusSnapshot: reads Threads_*, Connections, Aborted_* variables |
| ConnectionCounters | connectionUsageRatio() | Lazy-computed ratio (0.0β1.0) of threads_connected / max_connections |
| ConnectionCounters | isConnectionUsageCritical() | True when usage β₯80% (red threshold) |
| ConnectionDetailTabs | getDetails(processId) | Basic thread info from performance_schema.threads β returns null on 1142/1146/1227 |
| ConnectionDetailTabs | getAttributes(processId) | Session connect attrs from performance_schema.session_connect_attrs β returns null if no attrs or access denied |
| ConnectionDetailTabs | getMdlLocks(processId) | Metadata locks from performance_schema.metadata_locks (primary) with fallback to information_schema.metadata_lock_info |
| ConnectionDetailTabs | getThreadStack(processId) | Stack dump via sys.ps_thread_stack() β returns null if sys schema unavailable |
| ConnectionDetailTabs | getExplain(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). |
| ReportsPage | new(context, ?db) | Factory. validate() only loads Catalog (file I/O) β no blocking db->query() on render path. report queries routed through AdminQueryCache async. |
| ReportsPage | update(Msg) | Handles ReloadReportMsg (triggers loadCurrentReport), j/k navigate, r refresh, x export, c unit toggle, q quit. |
| ReportsPage | exportToCsv() | Export current report as RFC-4180 CSV string via CsvExporter::exportReportResultsToString(). Returns '' if no report loaded. |
| ReportRunner | run(viewName, ?limit) | Execute SELECT * FROM sys.<view> with unit formatting. Throws \InvalidArgumentException if view not in catalog, \RuntimeException if view not available on server. |
| ReportRunner | runRaw(viewName, ?limit) | Execute report without unit formatting β returns raw numeric values for sorting/calculation. |
| ReportRunner | canRun(viewName) | True if report exists in catalog and its view is available on this server. |
| AvailabilityChecker | discoverViews() | 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. |
| AvailabilityChecker | sysSchemaExists() | True if sys schema is accessible (runs discoverViews() internally). |
| AvailabilityChecker | isViewAvailable(viewName) | True if the named sys schema view exists on this server. |
| AvailabilityChecker | availableFromCatalog(catalog) | Return catalog reports whose views exist on this server. |
| AvailabilityChecker | missingFromCatalog(catalog) | Return catalog reports whose views are missing on this server. |
| AvailabilityChecker | reset() | 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. |
| VariablesPage | new(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. |
| VariablesPage | update(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. |
| VariableEditor | edit(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. |
| VariableEditor | persist(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. |
| VariableEditor | persistOnly(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. |
| VariableEditor | resetPersist(?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. |
| VariableEditor | getEditPreview(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'). |
| VariableEditor | isEditable(variableName) | True if the variable has editable=true in the metadata catalog. Returns false when catalog is absent (safer default). |
| VariableMetadata | isDynamic() | True if the variable has Dynamic=true in metadata (can be changed at runtime via SET GLOBAL). Non-dynamic variables hit MySQL error 1238. |
| VariableMetadata | inGroup(group) | True if the variable belongs to the given group name. |
| Catalog | load() | Load variable metadata from data/variable_metadata.json. Throws \RuntimeException on corrupt/missing file. |
| Catalog | get(variableName) | Return VariableMetadata for a variable, or null if not in catalog. |
| Catalog | all() | Return all VariableMetadata entries keyed by variable name. |
| Catalog | groups() | Return sorted list of all group names in the catalog. |
| Catalog | isEditable(variableName) | True if variable exists in catalog and VariableMetadata.editable is true. |
| Catalog | isDynamic(variableName) | True if variable exists in catalog and VariableMetadata.isDynamic() is true. |
| ExplainView | render() | Render plan as ANSI-coloured string |
| ExplainView | toArray() | Return JSON-serialisable plan rows (depth, tag, detail, indent) |
| ConnectionActions | setInstrumentation(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. |
| ConnectionActions | kill(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. |
| ConnectionActions | killQuery(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. |
| ConnectionDetailTabs | getExplain(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 |
| ConnectionFactory | fromDsn(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. |
| ConnectionFactory | fromConfig(config) | Build PDO from ConnectionConfig value object |
| ConnectionFactory | fromArgv() | Build PDO from CLI args (--db-driver, --db-host, --db-port, --db-user, --db-pass, --db-name, --db-ssl-mode) |
| Flavor | detectFromDriver(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. |
| Version | isAtLeast(Version) | Compare version strings (handles MariaDB 5.5.5- prefix) |
| ResultTable | fromRows(rows) | Factory from a list of associative row arrays |
| ResultTable | scrollLeft() / scrollRight() | Scroll viewport by one column |
| ResultTable | withVisibleWidth(n) / withJsonPretty(bool) / withNullToken(s) | Fluent configuration builders |
| ResultTable | render() / renderPlain() | Render ANSI string or plain text |
| CsvExporter | exportCsv(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) |
| CsvExporter | exportReportResults(path, columns, rows) | Write a result set to a CSV file with formula guard |
| CsvExporter | exportReportResultsToString(columns, rows) | Return a result set as an RFC-4180 CSV string (used by ReportsPage [x] export) |
| CsvExporter | importCsv(path, table) | Import a CSV file into a table (backtick-quoted columns, safe VALUES) |
| SqlExporter | exportSql(path) | Dump all tables as INSERT statements to a SQL file. No CREATE TABLE (not driver-neutral). Uses LIMIT 1 for column detection. |
| ReconnectManager | shouldReconnect(pdoException) | Check if error code is 2002/2003/2013 |
| ReconnectManager | attemptReconnect(callable) | Retry connection using stored ConnectionConfig |
| ReconnectManager | setConnectionConfig(config) / lastConnectionConfig() | Store/retrieve connection config for reconnection |
| ReconnectException | β | Thrown when reconnection fails after MySQL connection error |
| StatementTimeout | execute(stmt, values) | Execute with wall-clock timeout via pcntl_alarm() |
| StatementTimeout | didTimeout() | Check if last execution timed out |
| StatementTimeoutException | β | Thrown when statement exceeds timeout and is cancelled |
| Severity | toToastType() | Map to ToastType: InfoβInfo, WarningβWarning, CriticalβError |
| Alert | warning() / critical() / info() | Factory helpers for alert creation |
| Alert | isCritical() / isWarning() / isInfo() | Severity predicates |
| Alert | toToastMessage() | Format as "metric: message (X% > Y%)" |
| AlertThresholds | new() / default() / strict() | Factory presets: bare, 60%/80%, 50%/70% |
| AlertThresholds | connectionWarningThreshold() / connectionCriticalThreshold() | Connection usage ratio thresholds |
| AlertThresholds | withConnectionWarningThreshold() / withConnectionCriticalThreshold() | Fluent threshold setters (0.0β1.0) |
| AlertThresholds | abortedRateThreshold() / withAbortedRateThreshold() | Aborted connection rate threshold |
| AlertThresholds | slowQueryThreshold() / withSlowQueryThreshold() | Slow query time threshold in seconds |
| AlertThresholds | threadRunningThreshold() / withThreadRunningThreshold() | Thread running ratio threshold |
| AlertThresholds | connectionErrorsThreshold() / withConnectionErrorsThreshold() | Absolute connection error count threshold |
| AlertThresholds | watchedMetrics() / withWatchedMetrics() / watches() | Filter which metrics are actively watched |
| AlertThresholds | toastEnabled() / withToastEnabled() / toastPosition() / toastDuration() | Toast notification settings |
| AlertNotifier | new() / withDefaults() | Factory (muted by default when no factory provided) |
| AlertNotifier | notify(Alert) / notifyWarning() / notifyCritical() / notifyInfo() | Send notifications (all return new instance) |
| AlertNotifier | withMuted() / isMuted() | Mute state control |
| AlertNotifier | withToastFactory() | Late-bind toast factory |
| AlertNotifier | view(background, width, height) | Compose toast over viewport string |
| AlertNotifier | toast() / hasActiveAlert() | Direct toast access and state query |
| AlertManager | new() | Fresh manager with default thresholds |
| AlertManager | withThresholds() / withNotifier() | Attach threshold config and notifier |
| AlertManager | checkConnectionUsage(ConnectionCounters) | Evaluate connection counters, return array<string, Alert> |
| AlertManager | checkAllMetrics(statusVars, serverVars) | Evaluate SHOW GLOBAL STATUS/VARIABLES, return array<string, Alert> |
| AlertManager | checkAndDispatch(ConnectionCounters) | Combined check + notify, returns ['alerts', 'notifier'] |
| HistoryStoreInterface | save(entry) | Persist a history entry (query, duration_ms, rows_affected, error, ts) |
| HistoryStoreInterface | query(from, to, limit) | Retrieve history entries within time range |
| HistoryStoreInterface | prune(before) | Delete entries older than given DateTimeImmutable |
| HistoryStoreInterface | count() | Return total number of stored entries |
| SqliteHistoryStore | open(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(). |
| HistoryRecorder | provideStatusSnapshot(previous) | Implements StatusSnapshotProviderInterface. Calls store->save() when invoked by polling loop. |
| HistoryQuery | queriesPerSecond(from, to) | Compute QPS over a time range |
| HistoryQuery | averageDuration(from, to) | Compute mean execution time in ms over a time range |
| HistoryQuery | errorRate(from, to) | Compute fraction of queries that resulted in error |
| HistoryQuery | topQueries(limit) | Return most-frequent queries with counts |
| PerfSchemaPage | new(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. |
| PerfSchemaPage | update(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]. |
| PerfSchemaPage | instruments() / consumers() / actors() / objects() / threads() / timers() | Read current tab's model list. |
| PerfSchemaPage | activeTab() / selectedRowIndex() / isReadOnly() / setupState() | Read page state: active tab name, cursor index, privilege status, detected PS setup state (fully/default/custom/disabled). |
| SetupTimers | new(name, timerName) | Factory. |
| SetupTimers | withTimerName(timerName) | Return new instance with updated timer. Marks dirty and sets changeType to CHANGE_UPDATE. No-op when timer name is unchanged. |
| SetupTimers | isDirty() | True if timer has been modified since load. |
| SetupTimers | getChangeType() | Returns CHANGE_UPDATE or CHANGE_NONE. |
| SetupTimers | commitStatements() | 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). |
| SetupTimers | nameUpper() / isCycle() | Helpers: timer name in uppercase, true if CYCLE timer. |
| SetupThreads | new(threadId, name, type, ?processlistId, ?processlistUser, ?processlistCommand, ?processlistInfo, instrumented=true) | Factory. instrumented defaults to true. |
| SetupThreads | withInstrumented(bool) | Return new instance with updated INSTRUMENTED flag. Marks dirty. No-op when state is unchanged. |
| SetupThreads | isDirty() | True if thread's INSTRUMENTED state has been modified. |
| SetupThreads | isForeground() / isBackground() / hasProcesslist() | Type predicates. |
| SetupThreads | instrumentedFragment() | Returns SQL fragment: THREAD_ID = 123 AND INSTRUMENTED = 'YES' (or 'NO'). Used by CommitPlanner to build a batch UPDATE ... WHERE IN() clause. |
| SetupThreads | commitStatements() | Returns [] β individual threads delegate to CommitPlanner for the batch IN() update. Stub for interface compatibility. |
| CommitPlanner | new(instruments, consumers, actors, objects) | Factory. Takes arrays of dirty-tracking model instances. |
| CommitPlanner | commitAll() | 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. |
| CommitPlanner | commitInstruments() | 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>}>. |
| CommitPlanner | commitConsumers() | 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>}>. |
| CommitPlanner | commitActors() | 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>}>. |
| CommitPlanner | commitObjects() | 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>}>. |
| CommitPlanner | isDirty() | True if any tracked model is dirty. |
Manual verification checklist β CI runs unit tests against fakes; these steps validate end-to-end behavior against real database servers.
No live server needed. Tests the TUI layer and async cache with an in-memory SQLite connection.
php bin/candy-query --dsn sqlite:///tmp/smoke_test.db β launches; table list populates within 1 second.SELECT 1; result grid renders correctly.Requires a scratch MySQL 5.6+ instance. Validates all 7 admin panes, version gating, and write round-trips.
php bin/candy-query --dsn mysql://user:pass@localhost:3306/testdb β connects; status bar shows MySQL version.performance_schema = ON; KILL button kills a test connection cleanly; KILL QUERY aborts a running query.wait_timeout β [e] β change value β [Enter] confirm β verify variable updated via SHOW GLOBAL VARIABLES LIKE 'wait_timeout'.SELECT * FROM performance_schema.setup_instruments WHERE NAME = '...'.').SET @@GLOBAL.GTID_MODE = ....Requires a scratch PostgreSQL 10+ instance. Validates PostgresWidgetCatalog and pg_stat_database aggregation.
php bin/candy-query --dsn pgsql://user:pass@localhost:5432/testdb β connects; status bar shows PostgreSQL version.hasStoredPrograms() in ServerStatusPage renders via a sync information_schema.ROUTINES query. Low-overhead on typical servers; worth monitoring on routine-heavy schemas.array_search strict comparison β current enum value is shown verbatim; cycling starts at index 0 on mismatch. MySQL normalizes GTID_MODE to uppercase so this is academic.final β spy injection via ReflectionProperty::setValue() blocked by PHP typed-property enforcement. Dedup behavioral test deferred.VHS-recorded GIFs of every example shipped with the app. Regenerated automatically on every push that touches the source.