diff --git a/ext/mariadb_profiler/mariadb_profiler.c b/ext/mariadb_profiler/mariadb_profiler.c index 95ac35b..476f561 100644 --- a/ext/mariadb_profiler/mariadb_profiler.c +++ b/ext/mariadb_profiler/mariadb_profiler.c @@ -175,6 +175,11 @@ PHP_RINIT_FUNCTION(mariadb_profiler) profiler_ensure_log_dir(TSRMLS_C); /* Load active jobs at request start */ profiler_job_refresh_active_jobs(); +#if PHP_VERSION_ID >= 70000 + /* Initialize prepared statement query template storage */ + ALLOC_HASHTABLE(PROFILER_G(stmt_queries)); + zend_hash_init(PROFILER_G(stmt_queries), 16, NULL, ZVAL_PTR_DTOR, 0); +#endif } return SUCCESS; @@ -187,6 +192,14 @@ PHP_RSHUTDOWN_FUNCTION(mariadb_profiler) if (PROFILER_G(enabled)) { profiler_tag_clear_all(); profiler_job_free_active_jobs(); +#if PHP_VERSION_ID >= 70000 + /* Free prepared statement query template storage */ + if (PROFILER_G(stmt_queries)) { + zend_hash_destroy(PROFILER_G(stmt_queries)); + FREE_HASHTABLE(PROFILER_G(stmt_queries)); + PROFILER_G(stmt_queries) = NULL; + } +#endif } return SUCCESS; } diff --git a/ext/mariadb_profiler/php_mariadb_profiler.h b/ext/mariadb_profiler/php_mariadb_profiler.h index caaeaec..ca432f5 100644 --- a/ext/mariadb_profiler/php_mariadb_profiler.h +++ b/ext/mariadb_profiler/php_mariadb_profiler.h @@ -62,6 +62,10 @@ ZEND_BEGIN_MODULE_GLOBALS(mariadb_profiler) int tag_depth; /* Trace settings */ zend_long trace_depth; /* 0=disabled, N=capture N frames */ +#if PHP_VERSION_ID >= 70000 + /* Prepared statement query template storage (PHP 7.0+) */ + HashTable *stmt_queries; /* stmt ptr -> query template string */ +#endif ZEND_END_MODULE_GLOBALS(mariadb_profiler) /* Globals accessor: extern declaration for use across compilation units */ @@ -95,10 +99,13 @@ void profiler_job_free_active_jobs(void); int profiler_job_is_any_active(void); char **profiler_job_get_active_list(int *count); -/* Logging */ -void profiler_log_query(const char *query, size_t query_len); +/* Logging – status is "ok" or "err" (NULL treated as "ok") */ +void profiler_log_query(const char *query, size_t query_len, const char *status); +void profiler_log_query_with_params(const char *query, size_t query_len, + const char *params_json, const char *status); void profiler_log_raw(const char *job_key, const char *query, size_t query_len, - const char *tag, const char *trace_json); + const char *tag, const char *trace_json, + const char *params_json, const char *status); void profiler_log_init(void); void profiler_log_shutdown(void); diff --git a/ext/mariadb_profiler/php_mariadb_profiler_compat.h b/ext/mariadb_profiler/php_mariadb_profiler_compat.h index 05fcfc5..cbcbab8 100644 --- a/ext/mariadb_profiler/php_mariadb_profiler_compat.h +++ b/ext/mariadb_profiler/php_mariadb_profiler_compat.h @@ -179,4 +179,16 @@ typedef long zend_long; zend_hash_str_find((ht), (key), (key_len)) #endif +/* + * ---- bool type compatibility for mysqlnd stmt dtor ---- + * + * PHP 8.0+: uses C99 bool + * PHP 7.x: uses zend_bool (unsigned char) + */ +#if PHP_VERSION_ID >= 80000 +# define PROFILER_BOOL_T bool +#else +# define PROFILER_BOOL_T zend_bool +#endif + #endif /* PHP_MARIADB_PROFILER_COMPAT_H */ diff --git a/ext/mariadb_profiler/profiler_log.c b/ext/mariadb_profiler/profiler_log.c index f9502f7..bac853b 100644 --- a/ext/mariadb_profiler/profiler_log.c +++ b/ext/mariadb_profiler/profiler_log.c @@ -108,9 +108,10 @@ static double profiler_log_get_microtime(void) /* {{{ profiler_log_raw * Write raw query to job's raw log file. - * tag and trace_json may be NULL. */ + * tag, trace_json, params_json, and status may be NULL. */ void profiler_log_raw(const char *job_key, const char *query, size_t query_len, - const char *tag, const char *trace_json) + const char *tag, const char *trace_json, + const char *params_json, const char *status) { char *filepath; FILE *fp; @@ -132,9 +133,16 @@ void profiler_log_raw(const char *job_key, const char *query, size_t query_len, timestamp = profiler_log_get_timestamp(); if (tag) { - fprintf(fp, "[%s] [%s] %.*s\n", timestamp, tag, (int)query_len, query); + fprintf(fp, "[%s] [%s] [%s] %.*s\n", timestamp, + status ? status : "ok", tag, (int)query_len, query); } else { - fprintf(fp, "[%s] %.*s\n", timestamp, (int)query_len, query); + fprintf(fp, "[%s] [%s] %.*s\n", timestamp, + status ? status : "ok", (int)query_len, query); + } + + /* Append bound parameter values if present */ + if (params_json && params_json[0] == '[' && params_json[1] != ']') { + fprintf(fp, " params: %s\n", params_json); } /* Append trace lines (indented with arrow prefix) */ @@ -196,10 +204,11 @@ void profiler_log_raw(const char *job_key, const char *query, size_t query_len, /* {{{ profiler_log_jsonl * Write JSON line to job's parsed log file. - * tag and trace_json may be NULL. + * tag, trace_json, params_json, and status may be NULL. * SQL parsing (table/column extraction) is done by the CLI tool. */ static void profiler_log_jsonl(const char *job_key, const char *query, size_t query_len, - const char *tag, const char *trace_json) + const char *tag, const char *trace_json, + const char *params_json, const char *status) { char *filepath; FILE *fp; @@ -227,18 +236,27 @@ static void profiler_log_jsonl(const char *job_key, const char *query, size_t qu } ts = profiler_log_get_microtime(); - /* Build JSON line with optional tag and trace fields */ + /* Build JSON line with optional tag, params, and trace fields */ fprintf(fp, "{\"k\":\"%s\",\"q\":\"%s\"", escaped_key, escaped_query); if (escaped_tag) { fprintf(fp, ",\"tag\":\"%s\"", escaped_tag); } + /* params_json is already a valid JSON array string e.g. ["123","active",null] */ + if (params_json) { + fprintf(fp, ",\"params\":%s", params_json); + } + if (trace_json) { /* trace_json is already a valid JSON array string */ fprintf(fp, ",\"trace\":%s", trace_json); } + if (status) { + fprintf(fp, ",\"s\":\"%s\"", status); + } + fprintf(fp, ",\"ts\":%.6f}\n", ts); efree(escaped_query); @@ -252,10 +270,12 @@ static void profiler_log_jsonl(const char *job_key, const char *query, size_t qu } /* }}} */ -/* {{{ profiler_log_query - * Main entry point: log a query to all active jobs. +/* {{{ profiler_log_query_internal + * Internal: log a query to all active jobs with optional params and status. * Captures the current context tag and PHP trace once, shared across all jobs. */ -void profiler_log_query(const char *query, size_t query_len) +static void profiler_log_query_internal(const char *query, size_t query_len, + const char *params_json, + const char *status) { char **jobs; int job_count; @@ -276,11 +296,11 @@ void profiler_log_query(const char *query, size_t query_len) for (i = 0; i < job_count; i++) { /* Write JSONL entry */ - profiler_log_jsonl(jobs[i], query, query_len, tag, trace_json); + profiler_log_jsonl(jobs[i], query, query_len, tag, trace_json, params_json, status); /* Write raw log if enabled */ if (PROFILER_G(raw_log)) { - profiler_log_raw(jobs[i], query, query_len, tag, trace_json); + profiler_log_raw(jobs[i], query, query_len, tag, trace_json, params_json, status); } } @@ -290,6 +310,25 @@ void profiler_log_query(const char *query, size_t query_len) } /* }}} */ +/* {{{ profiler_log_query + * Main entry point: log a query (without params) to all active jobs. + * status is "ok" or "err" (NULL treated as "ok"). */ +void profiler_log_query(const char *query, size_t query_len, const char *status) +{ + profiler_log_query_internal(query, query_len, NULL, status); +} +/* }}} */ + +/* {{{ profiler_log_query_with_params + * Log a prepared statement query with bound parameter values to all active jobs. + * status is "ok" or "err" (NULL treated as "ok"). */ +void profiler_log_query_with_params(const char *query, size_t query_len, + const char *params_json, const char *status) +{ + profiler_log_query_internal(query, query_len, params_json, status); +} +/* }}} */ + /* {{{ profiler_log_init */ void profiler_log_init(void) { diff --git a/ext/mariadb_profiler/profiler_mysqlnd_plugin.c b/ext/mariadb_profiler/profiler_mysqlnd_plugin.c index 93f85ec..c9f57c1 100644 --- a/ext/mariadb_profiler/profiler_mysqlnd_plugin.c +++ b/ext/mariadb_profiler/profiler_mysqlnd_plugin.c @@ -13,6 +13,7 @@ #include "php.h" #include "php_mariadb_profiler.h" +#include "profiler_log.h" /* * mysqlnd internal API changed across PHP versions: @@ -60,13 +61,17 @@ MYSQLND_METHOD(profiler_conn, query)( const char *query, PROFILER_QUERY_LEN_T query_len TSRMLS_DC) { - /* Log the query if any job is active */ + enum_func_status result; + + /* Call the original method first */ + result = orig_conn_data_methods->query(conn, query, query_len TSRMLS_CC); + + /* Log the query with execution status */ if (PROFILER_G(enabled) && profiler_job_is_any_active()) { - profiler_log_query(query, query_len); + profiler_log_query(query, query_len, result == PASS ? "ok" : "err"); } - /* Call the original method */ - return orig_conn_data_methods->query(conn, query, query_len TSRMLS_CC); + return result; } /* }}} */ @@ -86,10 +91,12 @@ MYSQLND_METHOD(profiler_conn, send_query)( zval *read_cb, zval *err_cb) { + enum_func_status result; + result = orig_conn_data_methods->send_query(conn, query, query_len, read_cb, err_cb); if (PROFILER_G(enabled) && profiler_job_is_any_active()) { - profiler_log_query(query, query_len); + profiler_log_query(query, query_len, result == PASS ? "ok" : "err"); } - return orig_conn_data_methods->send_query(conn, query, query_len, read_cb, err_cb); + return result; } #elif PHP_VERSION_ID >= 70000 /* PHP 7.0-8.0: no TSRMLS, size_t, with type param */ @@ -102,10 +109,12 @@ MYSQLND_METHOD(profiler_conn, send_query)( zval *read_cb, zval *err_cb) { + enum_func_status result; + result = orig_conn_data_methods->send_query(conn, query, query_len, type, read_cb, err_cb); if (PROFILER_G(enabled) && profiler_job_is_any_active()) { - profiler_log_query(query, query_len); + profiler_log_query(query, query_len, result == PASS ? "ok" : "err"); } - return orig_conn_data_methods->send_query(conn, query, query_len, type, read_cb, err_cb); + return result; } #else /* PHP 5.3-5.6: simple signature (conn, query, query_len TSRMLS_DC) */ @@ -115,30 +124,287 @@ MYSQLND_METHOD(profiler_conn, send_query)( const char *query, unsigned int query_len TSRMLS_DC) { + enum_func_status result; + result = orig_conn_data_methods->send_query(conn, query, query_len TSRMLS_CC); if (PROFILER_G(enabled) && profiler_job_is_any_active()) { - profiler_log_query(query, query_len); + profiler_log_query(query, query_len, result == PASS ? "ok" : "err"); } - return orig_conn_data_methods->send_query(conn, query, query_len TSRMLS_CC); + return result; } #endif /* }}} */ /* {{{ profiler_stmt_prepare_hook - * Intercepts prepared statements at prepare time to log the query template */ + * Intercepts prepared statements at prepare time. + * PHP 7.0+: stores query template for later use at execute() time. + * PHP 5.x: logs template immediately (no param capture support). */ static enum_func_status MYSQLND_METHOD(profiler_stmt, prepare)( MYSQLND_STMT * const stmt, const char *query, PROFILER_QUERY_LEN_T query_len TSRMLS_DC) { - /* Log prepared statement query template */ + enum_func_status result; + + result = orig_stmt_methods->prepare(stmt, query, query_len TSRMLS_CC); + +#if PHP_VERSION_ID >= 70000 + if (PROFILER_G(enabled)) { + if (result == PASS && PROFILER_G(stmt_queries)) { + /* Store query template on success - will be logged at execute() with bound params */ + zval zv; + ZVAL_STRINGL(&zv, query, query_len); + zend_hash_index_update( + PROFILER_G(stmt_queries), + (zend_ulong)(uintptr_t)stmt, + &zv + ); + } else if (result != PASS && profiler_job_is_any_active()) { + /* Failed prepare has no subsequent execute(); log immediately with err status */ + profiler_log_query(query, query_len, "err"); + } + } +#else + /* PHP 5.x: log template at prepare time (no param support) */ if (PROFILER_G(enabled) && profiler_job_is_any_active()) { - profiler_log_query(query, query_len); + profiler_log_query(query, query_len, result == PASS ? "ok" : "err"); + } +#endif + + return result; +} +/* }}} */ + +#if PHP_VERSION_ID >= 70000 + +/* + * MYSQLND_VERSION_ID equals PHP_VERSION_ID. The internal layout of + * st_mysqlnd_stmt_data (fields param_bind, param_count) has been stable + * from PHP 7.0 through 8.4. Guard direct access so that an unknown + * future mysqlnd silently degrades to "no params" instead of crashing. + * + * NOTE: The upper bound (80500) is a defensive estimate – it should be + * updated once PHP 8.5 is released and its mysqlnd ABI has been verified. + * If the ABI remains unchanged, simply bump the upper bound. + */ +#define PROFILER_MYSQLND_PARAM_ACCESS_SAFE \ + (MYSQLND_VERSION_ID >= 70000 && MYSQLND_VERSION_ID < 80500) + +/* {{{ profiler_build_params_json_write_string + * Helper: write a JSON-escaped string value into the buffer. + * Grows the buffer as needed via *buf_ptr / *buf_size_ptr. + * Returns updated write position. */ +static size_t profiler_build_params_json_write_string( + char **buf_ptr, size_t *buf_size_ptr, size_t pos, + const char *str, size_t str_len) +{ + char *buf = *buf_ptr; + size_t buf_size = *buf_size_ptr; + char *escaped = profiler_log_escape_json_string(str, str_len); + size_t esc_len = strlen(escaped); + + if (pos + esc_len + 4 > buf_size) { + buf_size = pos + esc_len + 256; + buf = (char *)erealloc(buf, buf_size); + *buf_ptr = buf; + *buf_size_ptr = buf_size; + } + + buf[pos++] = '"'; + memcpy(buf + pos, escaped, esc_len); + pos += esc_len; + buf[pos++] = '"'; + efree(escaped); + return pos; +} + +/* {{{ profiler_build_params_json + * Build JSON array string from stmt's bound parameter values. + * Formats each value according to the declared bind type (MYSQL_TYPE_*) + * rather than the zval's runtime type, matching the coercion mysqlnd + * performs before sending data to the server. + * Returns emalloc'd string or NULL if no params. Caller must efree. */ +static char *profiler_build_params_json(MYSQLND_STMT * const stmt) +{ +#if PROFILER_MYSQLND_PARAM_ACCESS_SAFE + MYSQLND_STMT_DATA *data = stmt->data; + unsigned int i; + size_t buf_size, pos; + char *buf; + + if (!data || !data->param_bind || data->param_count == 0) { + return NULL; + } + + buf_size = 256; + buf = (char *)emalloc(buf_size); + pos = 0; + + buf[pos++] = '['; + + for (i = 0; i < data->param_count; i++) { + zval *zv = &data->param_bind[i].zv; + zend_uchar bind_type = data->param_bind[i].type; + + if (i > 0) { + buf[pos++] = ','; + } + + /* Ensure space for this param (grow if needed) */ + if (pos + 128 > buf_size) { + buf_size *= 2; + buf = (char *)erealloc(buf, buf_size); + } + + /* Dereference if reference (bind_param uses references) */ + ZVAL_DEREF(zv); + + /* NULL zval is always serialized as JSON null regardless of bind type */ + if (Z_TYPE_P(zv) == IS_NULL) { + memcpy(buf + pos, "null", 4); + pos += 4; + continue; + } + + /* + * Format according to the declared bind type rather than zval type, + * matching the coercion mysqlnd performs before sending to the server. + * 'i' -> MYSQL_TYPE_LONG 'd' -> MYSQL_TYPE_DOUBLE + * 's' -> MYSQL_TYPE_VAR_STRING 'b' -> MYSQL_TYPE_LONG_BLOB + */ + switch (bind_type) { + case MYSQL_TYPE_LONG: + case MYSQL_TYPE_LONGLONG: { + zend_long val = (Z_TYPE_P(zv) == IS_LONG) + ? Z_LVAL_P(zv) : zval_get_long(zv); + int written = snprintf(buf + pos, buf_size - pos, + "\"%ld\"", (long)val); + if (written < 0) { + break; + } + if ((size_t)written >= buf_size - pos) { + buf_size = pos + (size_t)written + 64; + buf = (char *)erealloc(buf, buf_size); + pos += snprintf(buf + pos, buf_size - pos, + "\"%ld\"", (long)val); + } else { + pos += (size_t)written; + } + break; + } + + case MYSQL_TYPE_DOUBLE: + case MYSQL_TYPE_FLOAT: { + double val = (Z_TYPE_P(zv) == IS_DOUBLE) + ? Z_DVAL_P(zv) : zval_get_double(zv); + int written = snprintf(buf + pos, buf_size - pos, + "\"%g\"", val); + if (written < 0) { + break; + } + if ((size_t)written >= buf_size - pos) { + buf_size = pos + (size_t)written + 64; + buf = (char *)erealloc(buf, buf_size); + pos += snprintf(buf + pos, buf_size - pos, + "\"%g\"", val); + } else { + pos += (size_t)written; + } + break; + } + + case MYSQL_TYPE_LONG_BLOB: + /* Blob data sent via send_long_data – log placeholder */ + memcpy(buf + pos, "\"[BLOB]\"", 8); + pos += 8; + break; + + default: { + /* String types (MYSQL_TYPE_VAR_STRING, etc.) and any + * unrecognised bind type: coerce to string */ + if (Z_TYPE_P(zv) == IS_STRING) { + pos = profiler_build_params_json_write_string( + &buf, &buf_size, pos, + Z_STRVAL_P(zv), Z_STRLEN_P(zv)); + } else { + zend_string *str = zval_get_string(zv); + pos = profiler_build_params_json_write_string( + &buf, &buf_size, pos, + ZSTR_VAL(str), ZSTR_LEN(str)); + zend_string_release(str); + } + break; + } + } + } + + buf[pos++] = ']'; + buf[pos] = '\0'; + + return buf; +#else + /* Unknown mysqlnd version – skip param capture to avoid ABI issues */ + (void)stmt; + return NULL; +#endif /* PROFILER_MYSQLND_PARAM_ACCESS_SAFE */ +} +/* }}} */ + +/* {{{ profiler_stmt_execute_hook + * Intercepts prepared statement execution to log query with bound params. + * Logging is performed after execute so the result status can be recorded. + * param_bind remains valid after execute (freed only on stmt dtor / rebind). */ +static enum_func_status +MYSQLND_METHOD(profiler_stmt, execute)( + MYSQLND_STMT * const stmt) +{ + enum_func_status result; + + /* Call the original method first */ + result = orig_stmt_methods->execute(stmt); + + /* Log with status and params after execution */ + if (PROFILER_G(enabled) && profiler_job_is_any_active() && PROFILER_G(stmt_queries)) { + zval *entry = zend_hash_index_find( + PROFILER_G(stmt_queries), + (zend_ulong)(uintptr_t)stmt + ); + if (entry && Z_TYPE_P(entry) == IS_STRING) { + char *params_json = profiler_build_params_json(stmt); + profiler_log_query_with_params( + Z_STRVAL_P(entry), Z_STRLEN_P(entry), params_json, + result == PASS ? "ok" : "err" + ); + if (params_json) { + efree(params_json); + } + } } - return orig_stmt_methods->prepare(stmt, query, query_len TSRMLS_CC); + + return result; } /* }}} */ +/* {{{ profiler_stmt_dtor_hook + * Clean up stored query template when statement is destroyed. */ +static enum_func_status +MYSQLND_METHOD(profiler_stmt, dtor)( + MYSQLND_STMT * const stmt, + PROFILER_BOOL_T implicit) +{ + if (PROFILER_G(stmt_queries)) { + zend_hash_index_del( + PROFILER_G(stmt_queries), + (zend_ulong)(uintptr_t)stmt + ); + } + + return orig_stmt_methods->dtor(stmt, implicit); +} +/* }}} */ + +#endif /* PHP_VERSION_ID >= 70000 */ + /* {{{ mariadb_profiler_mysqlnd_plugin_register */ void mariadb_profiler_mysqlnd_plugin_register(void) { @@ -180,5 +446,10 @@ void mariadb_profiler_mysqlnd_plugin_register(void) } stmt_methods->prepare = MYSQLND_METHOD(profiler_stmt, prepare); + +#if PHP_VERSION_ID >= 70000 + stmt_methods->execute = MYSQLND_METHOD(profiler_stmt, execute); + stmt_methods->dtor = MYSQLND_METHOD(profiler_stmt, dtor); +#endif } /* }}} */ diff --git a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/model/QueryEntry.kt b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/model/QueryEntry.kt index 2b61fae..430801e 100644 --- a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/model/QueryEntry.kt +++ b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/model/QueryEntry.kt @@ -11,9 +11,147 @@ data class QueryEntry( val timestamp: Double = 0.0, @SerialName("k") val jobKey: String = "", + @SerialName("s") + val status: String? = null, + @SerialName("tag") val tag: String? = null, + @SerialName("params") + val params: List = emptyList(), + @SerialName("trace") val trace: List = emptyList() ) { + /** Whether this query has bound parameters (prepared statement) */ + val hasParams: Boolean + get() = params.isNotEmpty() + + /** + * Query with `?` placeholders replaced by bound values. + * + * Replaces `?` characters that appear **outside** quoted contexts and + * SQL comments. The parser recognises: + * - Single-quoted string literals (`'...'`) with `''` and backslash + * escapes (`\'`, `\\`, etc.), matching MySQL/MariaDB default behavior + * (when `NO_BACKSLASH_ESCAPES` is not set) + * - Double-quoted string literals (`"..."`) with `""` as escaped quote + * - Backtick-quoted identifiers (`` `...` ``) + * - Line comments (`-- ...` where `--` is followed by whitespace) + * - Hash comments (`# ...`) + * - Block comments (`/* ... */`) + * + * Param values are wrapped in single quotes with internal backslashes + * doubled and single-quotes doubled; NULL params are emitted bare. + */ + val boundQuery: String? + get() { + if (params.isEmpty()) return null + + val sb = StringBuilder(query.length + params.size * 8) + var paramIdx = 0 + var i = 0 + + while (i < query.length) { + val ch = query[i] + + // -- line comment (MySQL/MariaDB requires space/tab/newline after --) + if (ch == '-' && i + 1 < query.length && query[i + 1] == '-' + && i + 2 < query.length && (query[i + 2] == ' ' || query[i + 2] == '\t' || query[i + 2] == '\n' || query[i + 2] == '\r')) { + val eol = query.indexOf('\n', i) + if (eol == -1) { + sb.append(query, i, query.length) + i = query.length + } else { + sb.append(query, i, eol + 1) + i = eol + 1 + } + continue + } + + // # line comment (MySQL/MariaDB): copy through to end of line + if (ch == '#') { + val eol = query.indexOf('\n', i) + if (eol == -1) { + sb.append(query, i, query.length) + i = query.length + } else { + sb.append(query, i, eol + 1) + i = eol + 1 + } + continue + } + + // /* block comment */: copy through to closing */ + if (ch == '/' && i + 1 < query.length && query[i + 1] == '*') { + val close = query.indexOf("*/", i + 2) + if (close == -1) { + sb.append(query, i, query.length) + i = query.length + } else { + sb.append(query, i, close + 2) + i = close + 2 + } + continue + } + + // Quoted context: single-quote, double-quote, or backtick + if (ch == '\'' || ch == '"' || ch == '`') { + val quote = ch + sb.append(ch) + i++ + while (i < query.length) { + val qch = query[i] + // Backslash escape inside single- and double-quoted strings + if (qch == '\\' && (quote == '\'' || quote == '"')) { + sb.append(qch) + if (i + 1 < query.length) { + sb.append(query[i + 1]) + i += 2 + } else { + i++ + } + continue + } + if (qch == quote) { + // doubled-quote escape ('' / "" inside their respective literals) + if (i + 1 < query.length && query[i + 1] == quote) { + sb.append(quote) + sb.append(quote) + i += 2 + continue + } + // closing quote + sb.append(quote) + i++ + break + } + sb.append(qch) + i++ + } + continue + } + + // Parameter placeholder + if (ch == '?') { + if (paramIdx < params.size) { + val v = params[paramIdx++] + if (v == null) { + sb.append("NULL") + } else { + sb.append('\'') + sb.append(v.replace("\\", "\\\\").replace("'", "''")) + sb.append('\'') + } + } else { + sb.append(ch) // more ?'s than params – keep as-is + } + i++ + continue + } + + sb.append(ch) + i++ + } + return sb.toString() + } /** Tag as list for UI display compatibility */ val tags: List get() = if (tag != null) listOf(tag) else emptyList() diff --git a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryDetailPanel.kt b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryDetailPanel.kt index 14f82f1..bf53cae 100644 --- a/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryDetailPanel.kt +++ b/jetbrains-plugin/src/main/kotlin/com/mariadbprofiler/plugin/ui/panel/QueryDetailPanel.kt @@ -29,6 +29,21 @@ class QueryDetailPanel(private val project: Project) : JPanel(BorderLayout()) { border = BorderFactory.createEmptyBorder(8, 8, 8, 8) } + private val paramsArea = JTextArea().apply { + isEditable = false + font = Font("JetBrains Mono", Font.PLAIN, 12).let { f -> + if (f.family == "JetBrains Mono") f else Font(Font.MONOSPACED, Font.PLAIN, 12) + } + lineWrap = true + wrapStyleWord = true + border = BorderFactory.createEmptyBorder(4, 8, 4, 8) + foreground = JBColor(0x6A1B9A, 0xCE93D8) + } + private val paramsPanel = JPanel(BorderLayout()).apply { + border = BorderFactory.createTitledBorder("Bound Parameters") + add(JBScrollPane(paramsArea).apply { preferredSize = Dimension(0, 60) }) + } + private val tablesLabel = JBLabel() private val tagsLabel = JBLabel() private val timestampLabel = JBLabel() @@ -87,10 +102,17 @@ class QueryDetailPanel(private val project: Project) : JPanel(BorderLayout()) { add(JBScrollPane(backtracePanel).apply { preferredSize = Dimension(0, 200) }) } + // Stack SQL + params + meta vertically, then backtrace at bottom + val topPanel = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(sqlPanel) + add(paramsPanel) + add(metaPanel) + } + contentPanel.apply { - add(sqlPanel, BorderLayout.NORTH) - add(metaPanel, BorderLayout.CENTER) - add(btPanel, BorderLayout.SOUTH) + add(topPanel, BorderLayout.NORTH) + add(btPanel, BorderLayout.CENTER) } cardPanel.add(emptyLabel, "empty") @@ -105,8 +127,22 @@ class QueryDetailPanel(private val project: Project) : JPanel(BorderLayout()) { return } - sqlArea.text = entry.query + // Show bound query (with params interpolated) if available, otherwise template + sqlArea.text = entry.boundQuery ?: entry.query sqlArea.caretPosition = 0 + + // Show params panel only for prepared statements with bound values + if (entry.hasParams) { + val paramLines = entry.params.mapIndexed { i, v -> + " ?${i + 1} = ${v ?: "NULL"}" + } + paramsArea.text = paramLines.joinToString("\n") + paramsArea.caretPosition = 0 + paramsPanel.isVisible = true + } else { + paramsPanel.isVisible = false + } + timestampLabel.text = entry.formattedTimestamp tablesLabel.text = entry.tables.joinToString(", ").ifEmpty { "-" } tagsLabel.text = entry.tags.joinToString(", ").ifEmpty { "-" } diff --git a/jetbrains-plugin/src/test/kotlin/com/mariadbprofiler/plugin/model/QueryEntryTest.kt b/jetbrains-plugin/src/test/kotlin/com/mariadbprofiler/plugin/model/QueryEntryTest.kt index a7f7a1a..ec43976 100644 --- a/jetbrains-plugin/src/test/kotlin/com/mariadbprofiler/plugin/model/QueryEntryTest.kt +++ b/jetbrains-plugin/src/test/kotlin/com/mariadbprofiler/plugin/model/QueryEntryTest.kt @@ -3,6 +3,7 @@ package com.mariadbprofiler.plugin.model import kotlinx.serialization.json.Json import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class QueryEntryTest { @@ -69,6 +70,165 @@ class QueryEntryTest { assertEquals("SELECT * FROM users", entry.shortSql) } + // ---- boundQuery tests ---- + + @Test + fun `boundQuery returns null when no params`() { + val entry = QueryEntry(query = "SELECT 1") + assertNull(entry.boundQuery) + } + + @Test + fun `boundQuery replaces single placeholder`() { + val entry = QueryEntry(query = "SELECT * FROM users WHERE id = ?", params = listOf("42")) + assertEquals("SELECT * FROM users WHERE id = '42'", entry.boundQuery) + } + + @Test + fun `boundQuery replaces multiple placeholders`() { + val entry = QueryEntry( + query = "INSERT INTO t (a, b, c) VALUES (?, ?, ?)", + params = listOf("x", "y", "z") + ) + assertEquals("INSERT INTO t (a, b, c) VALUES ('x', 'y', 'z')", entry.boundQuery) + } + + @Test + fun `boundQuery handles NULL params`() { + val entry = QueryEntry( + query = "INSERT INTO t (a, b) VALUES (?, ?)", + params = listOf(null, "val") + ) + assertEquals("INSERT INTO t (a, b) VALUES (NULL, 'val')", entry.boundQuery) + } + + @Test + fun `boundQuery keeps extra placeholders when fewer params than placeholders`() { + val entry = QueryEntry( + query = "SELECT * FROM t WHERE a = ? AND b = ? AND c = ?", + params = listOf("1") + ) + assertEquals("SELECT * FROM t WHERE a = '1' AND b = ? AND c = ?", entry.boundQuery) + } + + @Test + fun `boundQuery ignores extra params when more params than placeholders`() { + val entry = QueryEntry( + query = "SELECT * FROM t WHERE a = ?", + params = listOf("1", "2", "3") + ) + assertEquals("SELECT * FROM t WHERE a = '1'", entry.boundQuery) + } + + @Test + fun `boundQuery does not replace placeholder inside single-quoted string`() { + val entry = QueryEntry( + query = "SELECT * FROM t WHERE a = '?' AND b = ?", + params = listOf("val") + ) + assertEquals("SELECT * FROM t WHERE a = '?' AND b = 'val'", entry.boundQuery) + } + + @Test + fun `boundQuery handles escaped single quotes in SQL literal`() { + val entry = QueryEntry( + query = "SELECT * FROM t WHERE a = 'it''s' AND b = ?", + params = listOf("x") + ) + assertEquals("SELECT * FROM t WHERE a = 'it''s' AND b = 'x'", entry.boundQuery) + } + + @Test + fun `boundQuery escapes single quotes in param values`() { + val entry = QueryEntry( + query = "SELECT * FROM t WHERE name = ?", + params = listOf("O'Brien") + ) + assertEquals("SELECT * FROM t WHERE name = 'O''Brien'", entry.boundQuery) + } + + @Test + fun `boundQuery escapes backslashes in param values`() { + val entry = QueryEntry( + query = "SELECT * FROM t WHERE path = ?", + params = listOf("C:\\tmp") + ) + assertEquals("SELECT * FROM t WHERE path = 'C:\\\\tmp'", entry.boundQuery) + } + + @Test + fun `boundQuery escapes backslash and quote together`() { + val entry = QueryEntry( + query = "SELECT * FROM t WHERE v = ?", + params = listOf("a\\nb's") + ) + assertEquals("SELECT * FROM t WHERE v = 'a\\\\nb''s'", entry.boundQuery) + } + + @Test + fun `boundQuery does not replace placeholder inside double-quoted string`() { + val entry = QueryEntry( + query = """SELECT * FROM t WHERE a = "?" AND b = ?""", + params = listOf("val") + ) + assertEquals("""SELECT * FROM t WHERE a = "?" AND b = 'val'""", entry.boundQuery) + } + + @Test + fun `boundQuery handles escaped double quotes inside double-quoted string`() { + val entry = QueryEntry( + query = """SELECT * FROM t WHERE a = "say""what" AND b = ?""", + params = listOf("x") + ) + assertEquals("""SELECT * FROM t WHERE a = "say""what" AND b = 'x'""", entry.boundQuery) + } + + @Test + fun `boundQuery does not replace placeholder inside backtick-quoted identifier`() { + val entry = QueryEntry( + query = "SELECT `?` FROM t WHERE id = ?", + params = listOf("1") + ) + assertEquals("SELECT `?` FROM t WHERE id = '1'", entry.boundQuery) + } + + @Test + fun `boundQuery skips placeholder in line comment`() { + val entry = QueryEntry( + query = "SELECT * FROM t -- where id = ?\nWHERE name = ?", + params = listOf("test") + ) + assertEquals("SELECT * FROM t -- where id = ?\nWHERE name = 'test'", entry.boundQuery) + } + + @Test + fun `boundQuery skips placeholder in block comment`() { + val entry = QueryEntry( + query = "SELECT * FROM t /* ? */ WHERE id = ?", + params = listOf("42") + ) + assertEquals("SELECT * FROM t /* ? */ WHERE id = '42'", entry.boundQuery) + } + + @Test + fun `boundQuery handles block comment with quote inside`() { + val entry = QueryEntry( + query = "SELECT * FROM t /* it's ? */ WHERE id = ?", + params = listOf("1") + ) + assertEquals("SELECT * FROM t /* it's ? */ WHERE id = '1'", entry.boundQuery) + } + + @Test + fun `boundQuery handles unclosed block comment`() { + val entry = QueryEntry( + query = "SELECT * /* unclosed ? ", + params = listOf("x") + ) + // ? is inside unclosed comment, no replacement + assertEquals("SELECT * /* unclosed ? ", entry.boundQuery) + } + @Test fun `parse entry with backtrace`() { val jsonStr = """ @@ -88,4 +248,171 @@ class QueryEntryTest { assertEquals(42, entry.backtrace[0].line) assertEquals("UserController.php:42", entry.sourceFile) } + + @Test + fun `boundQuery replaces placeholders with params`() { + val entry = QueryEntry( + query = "SELECT * FROM users WHERE name = ? AND age = ?", + params = listOf("John", "25") + ) + assertEquals("SELECT * FROM users WHERE name = 'John' AND age = '25'", entry.boundQuery) + } + + @Test + fun `boundQuery escapes single quotes in params`() { + val entry = QueryEntry( + query = "SELECT * FROM users WHERE name = ?", + params = listOf("O'Brien") + ) + assertEquals("SELECT * FROM users WHERE name = 'O''Brien'", entry.boundQuery) + } + + @Test + fun `boundQuery does not replace placeholders inside single-quoted strings`() { + val entry = QueryEntry( + query = "SELECT * FROM users WHERE name = 'test?' AND age = ?", + params = listOf("25") + ) + assertEquals("SELECT * FROM users WHERE name = 'test?' AND age = '25'", entry.boundQuery) + } + + @Test + fun `boundQuery handles doubled single-quotes inside strings`() { + val entry = QueryEntry( + query = "SELECT * FROM users WHERE name = 'O''Brien' AND age = ?", + params = listOf("25") + ) + assertEquals("SELECT * FROM users WHERE name = 'O''Brien' AND age = '25'", entry.boundQuery) + } + + @Test + fun `boundQuery handles backslash-escaped single quotes`() { + val entry = QueryEntry( + query = "SELECT * FROM users WHERE name = 'O\\'Brien' AND age = ?", + params = listOf("25") + ) + assertEquals("SELECT * FROM users WHERE name = 'O\\'Brien' AND age = '25'", entry.boundQuery) + } + + @Test + fun `boundQuery handles backslash-escaped backslashes`() { + val entry = QueryEntry( + query = "SELECT * FROM paths WHERE path = 'C:\\\\Users\\\\test' AND id = ?", + params = listOf("1") + ) + assertEquals("SELECT * FROM paths WHERE path = 'C:\\\\Users\\\\test' AND id = '1'", entry.boundQuery) + } + + @Test + fun `boundQuery handles mixed escape sequences`() { + val entry = QueryEntry( + query = "SELECT * FROM data WHERE value = 'test\\nline' AND name = ?", + params = listOf("John") + ) + assertEquals("SELECT * FROM data WHERE value = 'test\\nline' AND name = 'John'", entry.boundQuery) + } + + @Test + fun `boundQuery handles placeholder at end of backslash-escaped string`() { + val entry = QueryEntry( + query = "SELECT * FROM users WHERE name = 'can\\'t' AND status = ?", + params = listOf("active") + ) + assertEquals("SELECT * FROM users WHERE name = 'can\\'t' AND status = 'active'", entry.boundQuery) + } + + @Test + fun `boundQuery handles more placeholders than params`() { + val entry = QueryEntry( + query = "SELECT * FROM users WHERE name = ? AND age = ? AND email = ?", + params = listOf("John", "25") + ) + // Should replace first two, leave third as-is + assertEquals("SELECT * FROM users WHERE name = 'John' AND age = '25' AND email = ?", entry.boundQuery) + } + + @Test + fun `boundQuery handles complex nested strings`() { + val entry = QueryEntry( + query = "SELECT * FROM logs WHERE msg = 'User said \\'hello\\'' AND id = ?", + params = listOf("123") + ) + assertEquals("SELECT * FROM logs WHERE msg = 'User said \\'hello\\'' AND id = '123'", entry.boundQuery) + } + + // ---- status field tests ---- + + @Test + fun `parse entry with status ok`() { + val jsonStr = """{"k":"job1","q":"SELECT 1","s":"ok","ts":1705970401.0}""" + val entry = json.decodeFromString(jsonStr) + assertEquals("ok", entry.status) + } + + @Test + fun `parse entry with status err`() { + val jsonStr = """{"k":"job1","q":"SELECT bad","s":"err","ts":1705970401.0}""" + val entry = json.decodeFromString(jsonStr) + assertEquals("err", entry.status) + } + + @Test + fun `parse entry without status defaults to null`() { + val jsonStr = """{"k":"job1","q":"SELECT 1","ts":1705970401.0}""" + val entry = json.decodeFromString(jsonStr) + assertNull(entry.status) + } + + // ---- hash comment tests ---- + + @Test + fun `boundQuery skips placeholder in hash comment`() { + val entry = QueryEntry( + query = "SELECT * FROM t # where id = ?\nWHERE name = ?", + params = listOf("test") + ) + assertEquals("SELECT * FROM t # where id = ?\nWHERE name = 'test'", entry.boundQuery) + } + + @Test + fun `boundQuery skips placeholder in hash comment at end of query`() { + val entry = QueryEntry( + query = "SELECT 1 # comment ?", + params = listOf("unused") + ) + assertEquals("SELECT 1 # comment ?", entry.boundQuery) + } + + // ---- double-dash comment spec tests ---- + + @Test + fun `boundQuery does not treat double-dash without trailing space as comment`() { + // MySQL/MariaDB requires whitespace after -- for it to be a line comment. + // "1--?" is arithmetic, not a comment. + val entry = QueryEntry( + query = "SELECT 1--?", + params = listOf("2") + ) + assertEquals("SELECT 1--'2'", entry.boundQuery) + } + + @Test + fun `boundQuery treats double-dash with tab as comment`() { + val entry = QueryEntry( + query = "SELECT * FROM t --\twhere id = ?\nWHERE name = ?", + params = listOf("test") + ) + assertEquals("SELECT * FROM t --\twhere id = ?\nWHERE name = 'test'", entry.boundQuery) + } + + @Test + fun `boundQuery treats double-dash with CRLF as comment`() { + // --\r\n is a comment that ends at the newline; "where id = ?" is on the + // next line so its ? is outside the comment and consumes the param. + val entry = QueryEntry( + query = "SELECT * FROM t --\r\nwhere id = ?\r\nAND name = ?", + params = listOf("test") + ) + assertEquals("SELECT * FROM t --\r\nwhere id = 'test'\r\nAND name = ?", entry.boundQuery) + } }