__( 'Get wpDataTable Data', 'wpdatatables' ), 'description' => __( 'Returns row data from a wpDataTable. For non-server-side tables the full dataset is returned in one call. For server-side tables (server_side=true in get-table-info) you MUST use the search and column_search parameters to filter data server-side rather than paginating through all rows manually. Use "search" for a global text search across all columns, or "column_search" to filter specific columns (e.g. {"Name": "bob"} to find rows where the Name column contains "bob"). You can also paginate (page, per_page), and sort (order_by, order_dir). The response includes column_headers with key/label/type so you know how to interpret the rows. Use get-table-info first to discover column names (orig_header) and whether the table is server-side. WHEN TO CALL: when the user asks to see, analyze, export, or answer questions about table contents. REQUIRED INPUT: table_id (integer). OPTIONAL: page, per_page, order_by (orig_header string), order_dir (asc|desc), search (global text), column_search (object mapping orig_header → value). DO NOT paginate through every page of a large server-side table — filter with search/column_search instead. TYPICAL WORKFLOW: list-tables → get-table-info → get-table-data. RETURNS: rows[], column_headers[], total_rows, page info, and server_side flag.', 'wpdatatables' ), 'category' => 'wpdatatables-data', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'table_id' => array( 'type' => 'integer', 'description' => 'The unique wpDataTable ID.', ), 'page' => array( 'type' => 'integer', 'description' => 'Page number (1-based). Only used for server-side tables. Defaults to 1.', ), 'per_page' => array( 'type' => 'integer', 'description' => 'Rows per page (max 500). Only used for server-side tables. Defaults to the table\'s display_length setting.', ), 'order_by' => array( 'type' => 'string', 'description' => 'Column orig_header to sort by. Only used for server-side tables.', ), 'order_dir' => array( 'type' => 'string', 'description' => 'Sort direction: "asc" or "desc". Defaults to "asc". Only used for server-side tables.', 'enum' => array( 'asc', 'desc' ), ), 'search' => array( 'type' => 'string', 'description' => 'Global search term: filters rows where ANY column contains this text (case-insensitive LIKE match). For server-side tables only. Example: "bob" finds all rows containing "bob" in any column.', ), 'column_search' => array( 'type' => 'object', 'description' => 'Per-column filters: an object mapping column orig_header names to search values. Each filter matches rows where that column contains the value (LIKE match). For server-side tables only. Example: {"Name": "bob", "CountryCode": "USA"} finds rows where Name contains "bob" AND CountryCode contains "USA".', 'additionalProperties' => array( 'type' => 'string' ), ), ), 'required' => array( 'table_id' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'table_id' => array( 'type' => 'integer', 'description' => 'The table ID that was queried.', ), 'table_type' => array( 'type' => 'string', 'description' => 'Source type of this table.', ), 'server_side' => array( 'type' => 'boolean', 'description' => 'Whether server-side processing is active.', ), 'column_headers' => array( 'type' => 'array', 'description' => 'Ordered list of columns with their key, display label, and data type.', 'items' => array( 'type' => 'object', 'properties' => array( 'key' => array( 'type' => 'string', 'description' => 'Column orig_header (use as row object key).' ), 'label' => array( 'type' => 'string', 'description' => 'Human-readable display header.' ), 'type' => array( 'type' => 'string', 'description' => 'Data type (string, int, float, date, datetime, time, etc.).' ), ), ), ), 'rows' => array( 'type' => 'array', 'description' => 'Array of row objects keyed by column orig_header.', 'items' => array( 'type' => 'object' ), ), 'total_rows' => array( 'type' => 'integer', 'description' => 'Total number of rows (before pagination/filtering).', ), 'filtered_rows' => array( 'type' => 'integer', 'description' => 'Number of rows matching the current filters (server-side only; equals total_rows otherwise).', ), 'page' => array( 'type' => 'integer', 'description' => 'Current page number (server-side only; 1 otherwise).', ), 'per_page' => array( 'type' => 'integer', 'description' => 'Rows per page used (server-side only; equals total_rows otherwise).', ), ), ), 'execute_callback' => 'wdtmcp_execute_get_table_data', 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'meta' => array( 'annotations' => array( 'instructions' => __( 'Requires table_id. Call get-table-info first. For server_side=true tables, filter with search or column_search instead of paginating through every page.', 'wpdatatables' ), 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), ), ) ); } /** * Execute callback for wpdatatables/get-table-data. * * @param array $input * @return array|WP_Error */ function wdtmcp_execute_get_table_data( $input ) { $table_id = isset( $input['table_id'] ) ? (int) $input['table_id'] : 0; if ( $table_id <= 0 ) { return new \WP_Error( 'wdtmcp_invalid_input', __( 'A valid table_id (positive integer) is required.', 'wpdatatables' ) ); } if ( ! class_exists( 'WDTConfigController' ) ) { return new \WP_Error( 'wdtmcp_missing_class', __( 'wpDataTables core class WDTConfigController is not available.', 'wpdatatables' ) ); } try { $table_config = \WDTConfigController::loadTableFromDB( $table_id ); } catch ( \Exception $e ) { return new \WP_Error( 'wdtmcp_load_error', sprintf( __( 'Error loading table %d: %s', 'wpdatatables' ), $table_id, $e->getMessage() ) ); } if ( empty( $table_config ) ) { return new \WP_Error( 'wdtmcp_not_found', sprintf( __( 'Table with ID %d was not found.', 'wpdatatables' ), $table_id ) ); } $table_type = isset( $table_config->table_type ) ? $table_config->table_type : ''; // Simple (spreadsheet) tables use a completely different storage model. if ( $table_type === 'simple' ) { return wdtmcp_get_simple_table_data( $table_id, $table_config ); } $is_server_side = ! empty( $table_config->server_side ); // Non-server-side: load everything via WPDataTable::loadWpDataTable(). if ( ! $is_server_side ) { return wdtmcp_get_full_table_data( $table_id, $table_config ); } // Server-side: build a controlled query with pagination/filter/sort. return wdtmcp_get_server_side_table_data( $table_id, $table_config, $input ); } /** * Build column_headers metadata from the table config's columns array. */ function wdtmcp_build_column_headers( $table_config ) { $headers = array(); if ( ! empty( $table_config->columns ) && is_array( $table_config->columns ) ) { foreach ( $table_config->columns as $col ) { if ( ! empty( $col->visible ) ) { $headers[] = array( 'key' => isset( $col->orig_header ) ? (string) $col->orig_header : '', 'label' => isset( $col->display_header ) ? (string) $col->display_header : '', 'type' => isset( $col->type ) ? (string) $col->type : 'string', ); } } } return $headers; } /** * Full load for non-server-side tables (CSV, Excel, JSON, Google Sheets, SQL * without server-side, manual without server-side, etc.). */ function wdtmcp_get_full_table_data( $table_id, $table_config ) { if ( ! class_exists( 'WPDataTable' ) ) { return new \WP_Error( 'wdtmcp_missing_class', __( 'WPDataTable class is not available.', 'wpdatatables' ) ); } try { $wdt = \WPDataTable::loadWpDataTable( $table_id, null, true ); } catch ( \Exception $e ) { return new \WP_Error( 'wdtmcp_load_error', sprintf( __( 'Error loading table data for table %d: %s', 'wpdatatables' ), $table_id, $e->getMessage() ) ); } $rows = $wdt->getDataRows(); $row_count = is_array( $rows ) ? count( $rows ) : 0; $table_type = isset( $table_config->table_type ) ? $table_config->table_type : ''; return array( 'table_id' => $table_id, 'table_type' => ( $table_type === 'mysql' ) ? 'SQL' : $table_type, 'server_side' => false, 'column_headers' => wdtmcp_build_column_headers( $table_config ), 'rows' => is_array( $rows ) ? array_values( $rows ) : array(), 'total_rows' => $row_count, 'filtered_rows' => $row_count, 'page' => 1, 'per_page' => $row_count, ); } /** * Paginated / filtered load for server-side tables. * * Instead of manipulating $_POST globals for wpDataTables' internal server-side * path, we run a controlled query directly against the table's SQL content. This * keeps things safe (our own parameter allowlisting) and avoids side-effects. * * Mirrors the relevant parts of WPDataTable::queryBasedConstruct(): * - Applies wpDataTables filter hooks so third-party code can modify queries. * - Uses vendor-aware LIMIT/OFFSET syntax (MySQL, MSSQL, PostgreSQL). * - Uses vendor-aware LIKE expressions (PostgreSQL LOWER(CAST(...))). */ function wdtmcp_get_server_side_table_data( $table_id, $table_config, $input ) { global $wpdb; $base_query = isset( $table_config->content ) ? $table_config->content : ''; if ( empty( $base_query ) ) { return new \WP_Error( 'wdtmcp_no_query', __( 'Server-side table has no SQL query content.', 'wpdatatables' ) ); } $column_headers = wdtmcp_build_column_headers( $table_config ); $valid_columns = array_column( $column_headers, 'key' ); if ( empty( $valid_columns ) ) { return new \WP_Error( 'wdtmcp_no_columns', __( 'No visible columns found for this table.', 'wpdatatables' ) ); } // Resolve connection vendor (MySQL is the default for the WP connection). $connection_name = isset( $table_config->connection ) ? $table_config->connection : ''; $use_separate = class_exists( 'Connection' ) && \Connection::isSeparate( $connection_name ); $vendor = 'mysql'; $lq = '`'; $rq = '`'; if ( $use_separate ) { $vendor = \Connection::getVendor( $connection_name ); $lq = \Connection::getLeftColumnQuote( $vendor ); $rq = \Connection::getRightColumnQuote( $vendor ); } $is_mysql = ( $vendor === 'mysql' ); $is_mssql = ( $vendor === 'mssql' ); $is_postgresql = ( $vendor === 'postgresql' ); // Apply placeholder variables ($wdtVar1 … $wdtVar9) and sanitize. if ( class_exists( 'WDTTools' ) ) { $base_query = \WDTTools::applyPlaceholders( $base_query ); } if ( function_exists( 'wdtSanitizeQuery' ) ) { $base_query = wdtSanitizeQuery( $base_query ); } // Hook: let third-party code modify the query before LIMIT is appended. $base_query = apply_filters( 'wpdatatables_filter_query_before_limit', $base_query, $table_id ); $wrapped = "SELECT * FROM ({$base_query}) AS wdtmcp_data"; // ----- WHERE clause -------------------------------------------------- $where_parts = array(); if ( ! empty( $input['search'] ) && is_string( $input['search'] ) ) { $like_parts = array(); foreach ( $valid_columns as $col ) { $like_parts[] = wdtmcp_like_expr( $vendor, $lq, $rq, $col, $input['search'], $wpdb ); } if ( ! empty( $like_parts ) ) { $where_parts[] = '(' . implode( ' OR ', $like_parts ) . ')'; } } if ( ! empty( $input['column_search'] ) && is_array( $input['column_search'] ) ) { foreach ( $input['column_search'] as $col => $val ) { if ( ! in_array( $col, $valid_columns, true ) ) { continue; } $where_parts[] = wdtmcp_like_expr( $vendor, $lq, $rq, $col, $val, $wpdb ); } } $where_sql = ''; if ( ! empty( $where_parts ) ) { $where_sql = ' WHERE ' . implode( ' AND ', $where_parts ); } // ----- ORDER BY ------------------------------------------------------ $order_sql = ''; if ( ! empty( $input['order_by'] ) && in_array( $input['order_by'], $valid_columns, true ) ) { $dir = ( ! empty( $input['order_dir'] ) && strtolower( $input['order_dir'] ) === 'desc' ) ? 'DESC' : 'ASC'; $order_sql = " ORDER BY {$lq}{$input['order_by']}{$rq} {$dir}"; } // ----- Pagination (vendor-aware LIMIT/OFFSET) ------------------------ $display_length = isset( $table_config->display_length ) ? (int) $table_config->display_length : 25; $per_page = isset( $input['per_page'] ) ? min( max( (int) $input['per_page'], 1 ), 500 ) : $display_length; $page = isset( $input['page'] ) ? max( (int) $input['page'], 1 ) : 1; $offset = ( $page - 1 ) * $per_page; $limit_sql = wdtmcp_limit_expr( $vendor, $per_page, $offset, $order_sql ); // ----- Build final SQL statements ------------------------------------ $count_total_sql = "SELECT COUNT(*) FROM ({$base_query}) AS wdtmcp_cnt"; $count_filtered_sql = "SELECT COUNT(*) FROM ({$base_query}) AS wdtmcp_data{$where_sql}"; $data_sql = "{$wrapped}{$where_sql}{$order_sql}{$limit_sql}"; // Hook: let third-party code modify the final data query. $data_sql = apply_filters( 'wpdatatables_filter_mysql_query', $data_sql, $table_id ); // ----- Execute ------------------------------------------------------- if ( $use_separate ) { $sql_link = \Connection::getInstance( $connection_name ); $total_raw = $sql_link->getField( $count_total_sql ); if ( false === $total_raw ) { $last_err = method_exists( $sql_link, 'getLastError' ) ? $sql_link->getLastError() : ''; if ( ! empty( $last_err ) ) { return new \WP_Error( 'wdtmcp_query_error', 'Count query failed: ' . $last_err ); } $total_raw = 0; } $total_rows = (int) $total_raw; $filtered_raw = $sql_link->getField( $count_filtered_sql ); $filtered = ( false !== $filtered_raw ) ? (int) $filtered_raw : 0; $rows = $sql_link->getAssoc( $data_sql ); if ( false === $rows || ! is_array( $rows ) ) { $last_err = method_exists( $sql_link, 'getLastError' ) ? $sql_link->getLastError() : ''; if ( ! empty( $last_err ) ) { return new \WP_Error( 'wdtmcp_query_error', 'Data query failed: ' . $last_err ); } $rows = array(); } } else { $total_rows = (int) $wpdb->get_var( $count_total_sql ); $filtered = (int) $wpdb->get_var( $count_filtered_sql ); $rows = $wpdb->get_results( $data_sql, ARRAY_A ); if ( ! is_array( $rows ) ) { return new \WP_Error( 'wdtmcp_query_error', __( 'Failed to retrieve data from the table.', 'wpdatatables' ) . ( ! empty( $wpdb->last_error ) ? ' ' . $wpdb->last_error : '' ) ); } } $table_type = isset( $table_config->table_type ) ? $table_config->table_type : ''; return array( 'table_id' => $table_id, 'table_type' => ( $table_type === 'mysql' ) ? 'SQL' : $table_type, 'server_side' => true, 'column_headers' => $column_headers, 'rows' => $rows, 'total_rows' => $total_rows, 'filtered_rows' => $filtered, 'page' => $page, 'per_page' => $per_page, ); } /** * Build a vendor-appropriate LIKE expression for a single column. * * Matches the patterns used in WPDataTable::getLikeExpression(): * - MySQL / MSSQL : `col` LIKE '%value%' * - PostgreSQL : LOWER(CAST("col" AS TEXT)) LIKE LOWER('%value%') * * @param string $vendor Connection vendor identifier. * @param string $lq Left column quote character. * @param string $rq Right column quote character. * @param string $column Column orig_header. * @param string $value Raw search value (will be escaped). * @param wpdb $wpdb WordPress database object (used for escaping). * @return string SQL fragment. */ function wdtmcp_like_expr( $vendor, $lq, $rq, $column, $value, $wpdb ) { $like_value = '%' . $wpdb->esc_like( $value ) . '%'; if ( $vendor === 'postgresql' ) { return $wpdb->prepare( "LOWER(CAST({$lq}{$column}{$rq} AS TEXT)) LIKE LOWER(%s)", $like_value ); } return $wpdb->prepare( "{$lq}{$column}{$rq} LIKE %s", $like_value ); } /** * Build a vendor-appropriate LIMIT/OFFSET clause. * * Matches the patterns used in WPDataTable::queryBasedConstruct(): * - MySQL : LIMIT {per_page} OFFSET {offset} * - PostgreSQL : LIMIT {per_page} OFFSET {offset} * - MSSQL : [ORDER BY (SELECT NULL)] OFFSET {offset} ROWS FETCH NEXT {per_page} ROWS ONLY * * @param string $vendor Connection vendor identifier. * @param int $per_page Number of rows to return. * @param int $offset Number of rows to skip. * @param string $order_sql The ORDER BY clause already built (empty string if none). * @return string SQL fragment. */ function wdtmcp_limit_expr( $vendor, $per_page, $offset, $order_sql ) { if ( $vendor === 'mssql' ) { $needs_default_order = empty( $order_sql ); return ( $needs_default_order ? ' ORDER BY (SELECT NULL)' : '' ) . " OFFSET {$offset} ROWS FETCH NEXT {$per_page} ROWS ONLY"; } // MySQL and PostgreSQL share the same LIMIT/OFFSET syntax. return " LIMIT {$per_page} OFFSET {$offset}"; } /** * Simple (spreadsheet) tables store data in wp_wpdatatables_rows as JSON. */ function wdtmcp_get_simple_table_data( $table_id, $table_config ) { if ( ! class_exists( 'WPDataTableRows' ) ) { return new \WP_Error( 'wdtmcp_missing_class', __( 'WPDataTableRows class is not available.', 'wpdatatables' ) ); } try { $wdt_rows = \WPDataTableRows::loadWpDataTableRows( $table_id ); } catch ( \Exception $e ) { return new \WP_Error( 'wdtmcp_load_error', sprintf( __( 'Error loading simple table %d: %s', 'wpdatatables' ), $table_id, $e->getMessage() ) ); } $col_headers_raw = $wdt_rows->getColHeaders(); $column_headers = array(); foreach ( $col_headers_raw as $idx => $label ) { $column_headers[] = array( 'key' => 'col_' . $idx, 'label' => (string) $label, 'type' => 'string', ); } $raw_rows = $wdt_rows->getRowsData(); $rows = array(); foreach ( $raw_rows as $row_obj ) { $row = array(); if ( isset( $row_obj->cells ) && is_array( $row_obj->cells ) ) { foreach ( $row_obj->cells as $idx => $cell ) { $key = 'col_' . $idx; $row[ $key ] = isset( $cell->data ) ? $cell->data : ''; } } $rows[] = $row; } $row_count = count( $rows ); return array( 'table_id' => $table_id, 'table_type' => 'simple', 'server_side' => false, 'column_headers' => $column_headers, 'rows' => $rows, 'total_rows' => $row_count, 'filtered_rows' => $row_count, 'page' => 1, 'per_page' => $row_count, ); }