diff --git a/dist/feca_mailshots_plugin-1.1.17.zip b/dist/feca_mailshots_plugin-1.1.17.zip new file mode 100644 index 0000000..693aa52 Binary files /dev/null and b/dist/feca_mailshots_plugin-1.1.17.zip differ diff --git a/dist/feca_mailshots_plugin-1.1.18.zip b/dist/feca_mailshots_plugin-1.1.18.zip new file mode 100644 index 0000000..cda40e7 Binary files /dev/null and b/dist/feca_mailshots_plugin-1.1.18.zip differ diff --git a/dist/feca_mailshots_plugin-1.1.19.zip b/dist/feca_mailshots_plugin-1.1.19.zip new file mode 100644 index 0000000..30bb87a Binary files /dev/null and b/dist/feca_mailshots_plugin-1.1.19.zip differ diff --git a/feca_mailshots_plugin/feca_mailshots_plugin.php b/feca_mailshots_plugin/feca_mailshots_plugin.php index 8cd9918..7b651df 100644 --- a/feca_mailshots_plugin/feca_mailshots_plugin.php +++ b/feca_mailshots_plugin/feca_mailshots_plugin.php @@ -3,7 +3,7 @@ * Plugin Name: FECA Mailshots * Plugin URI: https://fenedge.co.uk/ * Description: FECA mailshots plugin. - * Version: 1.1.11 + * Version: 1.1.20 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: FECA diff --git a/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php b/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php index 2633784..aa48b93 100644 --- a/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php +++ b/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php @@ -88,10 +88,11 @@ final class DataSourcesAdminPage $editId = $draftId; } $editItem = null; - foreach ($items as $row) { - if ((int) ($row['ID'] ?? 0) === $editId) { - $editItem = $row; - break; + if ($editId > 0) { + try { + $editItem = $this->service()->get($editId); + } catch (\Throwable $e) { + $editItem = null; } } $name = is_array($editItem) ? (string) ($editItem['name'] ?? '') : ''; diff --git a/feca_mailshots_plugin/src/Admin/MailshotsAdminPage.php b/feca_mailshots_plugin/src/Admin/MailshotsAdminPage.php index 4610164..67676e7 100644 --- a/feca_mailshots_plugin/src/Admin/MailshotsAdminPage.php +++ b/feca_mailshots_plugin/src/Admin/MailshotsAdminPage.php @@ -74,10 +74,11 @@ final class MailshotsAdminPage $editId = $draftId; } $editItem = null; - foreach ($items as $row) { - if ((int) ($row['id'] ?? 0) === $editId) { - $editItem = $row; - break; + if ($editId > 0) { + try { + $editItem = $this->service()->find($editId); + } catch (\Throwable $e) { + $editItem = null; } } diff --git a/feca_mailshots_plugin/src/Admin/ReviewRecipientsAdminPage.php b/feca_mailshots_plugin/src/Admin/ReviewRecipientsAdminPage.php index 3ed225c..cb0dffb 100644 --- a/feca_mailshots_plugin/src/Admin/ReviewRecipientsAdminPage.php +++ b/feca_mailshots_plugin/src/Admin/ReviewRecipientsAdminPage.php @@ -13,6 +13,8 @@ final class ReviewRecipientsAdminPage private const CAPABILITY = 'edit_pages'; private const PAGE_SLUG = 'feca-mailshots-review-recipients'; + private const DEFAULT_PAGE_SIZE = 200; + private const MAX_PAGE_SIZE = 500; /** @var callable(): DataSourceService */ private $serviceFactory; @@ -43,26 +45,13 @@ final class ReviewRecipientsAdminPage return; } - $service = $this->service(); - $sources = $service->list(); + $sources = $this->service()->list(); $selectedSource = trim((string) ($this->wp->requestParam('data_source', '') ?? '')); - - $initialRows = []; - $initialColumns = []; - $initialCount = 0; - $initialErrors = []; - if ($selectedSource !== '') { - $dsl = $this->dslForSource($sources, $selectedSource); - if ($dsl === '') { - $initialErrors[] = 'Selected data source was not found.'; - } else { - $result = $service->review($dsl); - $initialErrors = array_values(array_map('strval', (array) ($result['errors'] ?? []))); - if ($initialErrors === []) { - $initialRows = is_array($result['rows'] ?? null) ? $result['rows'] : []; - $initialColumns = is_array($result['columns'] ?? null) ? array_values(array_map('strval', $result['columns'])) : []; - $initialCount = (int) ($result['count'] ?? count($initialRows)); - } + $sourceNames = []; + foreach ($sources as $source) { + $name = trim((string) ($source['name'] ?? '')); + if ($name !== '') { + $sourceNames[] = $name; } } @@ -76,18 +65,14 @@ final class ReviewRecipientsAdminPage echo ''; echo ''; echo '
'; echo ''; - echo ''; + echo ''; echo '
'; echo '
'; echo ''; @@ -99,96 +84,79 @@ final class ReviewRecipientsAdminPage echo '
'; echo ''; - echo '
'; - echo 'Select a data source to load recipients.'; - echo '
'; + echo '
Select a data source to load recipients.
'; echo '
'; - echo '
'; - echo '
'; + echo '
'; echo ''; echo ''; echo ''; echo '
No recipients loaded.
'; - echo '
'; - echo '
'; + echo '
'; + echo '

'; - $sourceDslMap = []; - foreach ($sources as $source) { - $name = trim((string) ($source['name'] ?? '')); - if ($name === '') { - continue; - } - $sourceDslMap[$name] = trim((string) ($source['dsl_text'] ?? '')); - } echo ''; + echo ''; } public function handleApi(): void { + $stage = 'initializing review recipients API'; + $this->registerFatalJsonTrap('review recipients API', $stage); + if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) { return; } @@ -205,42 +173,111 @@ final class ReviewRecipientsAdminPage return; } - $sources = $this->service()->list(); - $dsl = $this->dslForSource($sources, $sourceName); - if ($dsl === '') { - $this->wp->sendJson(['ok' => false, 'errors' => ['Selected data source was not found.']], 404); - return; - } + $limit = max(1, min(self::MAX_PAGE_SIZE, $this->requestInt('limit', self::DEFAULT_PAGE_SIZE))); + $offset = max(0, $this->requestInt('offset', 0)); try { - $result = $this->service()->review($dsl); + $stage = 'loading selected data source'; + $dsl = $this->dslForSource($sourceName); + if ($dsl === '') { + $this->wp->sendJson(['ok' => false, 'errors' => ['Selected data source was not found.']], 404); + return; + } + + $stage = 'querying recipient rows'; + $result = $this->service()->review($dsl, $limit, $offset); $errors = array_values(array_map('strval', (array) ($result['errors'] ?? []))); if ($errors !== []) { $this->wp->sendJson(['ok' => false, 'errors' => $errors], 400); return; } - $this->wp->sendJson([ + + $stage = 'encoding recipient rows'; + $payload = [ 'ok' => true, 'source' => $sourceName, 'rows' => is_array($result['rows'] ?? null) ? $result['rows'] : [], 'columns' => is_array($result['columns'] ?? null) ? $result['columns'] : [], 'count' => (int) ($result['count'] ?? 0), - ]); + 'limit' => (int) ($result['limit'] ?? $limit), + 'offset' => (int) ($result['offset'] ?? $offset), + 'returned_count' => (int) ($result['returned_count'] ?? 0), + 'has_more' => !empty($result['has_more']), + 'diagnostics' => $this->diagnostics($stage), + ]; + $this->wp->sendJson($payload); } catch (\Throwable $e) { - $this->wp->sendJson(['ok' => false, 'errors' => [$e->getMessage()]], 500); + $this->wp->sendJson([ + 'ok' => false, + 'errors' => [$this->diagnosticError('Review Recipients API failed', $stage, $e)], + 'diagnostics' => $this->diagnostics($stage), + ], 500); } } - /** @param list> $sources */ - private function dslForSource(array $sources, string $sourceName): string + private function dslForSource(string $sourceName): string { - foreach ($sources as $source) { - $name = trim((string) ($source['name'] ?? '')); - if ($name === $sourceName) { - return trim((string) ($source['dsl_text'] ?? '')); - } + $source = $this->service()->getByName($sourceName); + return is_array($source) ? trim((string) ($source['dsl_text'] ?? '')) : ''; + } + + /** @return array */ + private function diagnostics(string $stage): array + { + return [ + 'stage' => $stage, + 'memory_usage' => function_exists('memory_get_usage') ? memory_get_usage(true) : null, + 'memory_peak' => function_exists('memory_get_peak_usage') ? memory_get_peak_usage(true) : null, + ]; + } + + private function registerFatalJsonTrap(string $context, string &$stage): void + { + if (!function_exists('register_shutdown_function')) { + return; } - return ''; + + register_shutdown_function(function () use ($context, &$stage): void { + $error = error_get_last(); + if (!is_array($error)) { + return; + } + $type = (int) ($error['type'] ?? 0); + if (!in_array($type, [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR], true)) { + return; + } + + $message = trim((string) ($error['message'] ?? 'Unknown fatal error.')); + $file = basename((string) ($error['file'] ?? 'unknown')); + $line = (int) ($error['line'] ?? 0); + $payload = [ + 'ok' => false, + 'errors' => [ + 'Mailshots ' . $context . ' fatal error during ' . $stage . ': ' . $message . ' [' . $file . ':' . $line . ']', + ], + 'diagnostics' => $this->diagnostics($stage), + ]; + if (!headers_sent()) { + if (function_exists('status_header')) { + status_header(500); + } else { + http_response_code(500); + } + header('Content-Type: application/json; charset=UTF-8'); + } + echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + }); + } + + private function diagnosticError(string $prefix, string $stage, \Throwable $e): string + { + $message = $prefix . ' during ' . $stage . ': ' . $e->getMessage(); + $file = $e->getFile(); + $line = $e->getLine(); + if ($file !== '' && $line > 0) { + $message .= ' [' . get_class($e) . ' at ' . basename($file) . ':' . $line . ']'; + } + return $message; } private function service(): DataSourceService diff --git a/feca_mailshots_plugin/src/Application/DataSourceService.php b/feca_mailshots_plugin/src/Application/DataSourceService.php index 9a3ee64..0ccf653 100644 --- a/feca_mailshots_plugin/src/Application/DataSourceService.php +++ b/feca_mailshots_plugin/src/Application/DataSourceService.php @@ -52,6 +52,12 @@ final class DataSourceService return $this->queries->find($id); } + /** @return array|null */ + public function getByName(string $name): ?array + { + return $this->queries->findByName($name); + } + /** @return array */ public function validateDsl(string $dsl): array { @@ -131,17 +137,19 @@ final class DataSourceService public function preview(string $dsl, int $limit = 50): array { $limit = max(1, min(200, $limit)); - return $this->queryRows($dsl, $limit); + return $this->queryRows($dsl, $limit, 0); } /** @return array */ - public function review(string $dsl): array + public function review(string $dsl, int $limit = 200, int $offset = 0): array { - return $this->queryRows($dsl, null); + $limit = max(1, min(500, $limit)); + $offset = max(0, $offset); + return $this->queryRows($dsl, $limit, $offset); } /** @return array */ - private function queryRows(string $dsl, ?int $limit): array + private function queryRows(string $dsl, int $limit, int $offset): array { $validation = $this->validateDsl($dsl); if ($validation['errors'] !== []) { @@ -150,19 +158,17 @@ final class DataSourceService $ast = $validation['ast']; $compiled = $this->compiler->compile($ast); + $countCompiled = $this->compiler->compileCountable($ast); - $countSql = 'SELECT COUNT(*) FROM (' . $compiled['sql'] . ') AS q'; + $countSql = 'SELECT COUNT(*) FROM (' . $countCompiled['sql'] . ') AS q'; $stmtCount = $this->router->membersPdo()->prepare($countSql); - $stmtCount->execute($compiled['params']); + $stmtCount->execute($countCompiled['params']); $count = (int) $stmtCount->fetchColumn(); - $previewSql = $compiled['sql']; - if ($limit !== null) { - $previewSql .= ' LIMIT ' . $limit; - } + $previewSql = $compiled['sql'] . ' LIMIT ' . $limit . ' OFFSET ' . $offset; $stmtRows = $this->router->membersPdo()->prepare($previewSql); $stmtRows->execute($compiled['params']); - $rows = $stmtRows->fetchAll(PDO::FETCH_ASSOC); + $rows = $this->normalizeRowsForJson($stmtRows->fetchAll(PDO::FETCH_ASSOC)); $columnSet = []; foreach ($rows as $row) { @@ -178,9 +184,44 @@ final class DataSourceService 'rows' => $rows, 'columns' => array_keys($columnSet), 'expected_fields' => $validation['expected_fields'], + 'limit' => $limit, + 'offset' => $offset, + 'returned_count' => count($rows), + 'has_more' => ($offset + count($rows)) < $count, ]; } + /** @param list> $rows @return list> */ + private function normalizeRowsForJson(array $rows): array + { + foreach ($rows as &$row) { + foreach ($row as $key => $value) { + if (is_string($value)) { + $row[$key] = $this->jsonSafeString($value); + } + } + } + unset($row); + return $rows; + } + + private function jsonSafeString(string $value): string + { + if ($value === '' || preg_match('//u', $value) === 1) { + return $value; + } + if (function_exists('mb_convert_encoding')) { + return mb_convert_encoding($value, 'UTF-8', 'UTF-8'); + } + if (function_exists('iconv')) { + $converted = @iconv('UTF-8', 'UTF-8//IGNORE', $value); + if (is_string($converted)) { + return $converted; + } + } + return ''; + } + /** @return array> */ public function sourceFields(): array { diff --git a/feca_mailshots_plugin/src/Application/DslCompiler.php b/feca_mailshots_plugin/src/Application/DslCompiler.php index 286ccc2..85ab8ae 100644 --- a/feca_mailshots_plugin/src/Application/DslCompiler.php +++ b/feca_mailshots_plugin/src/Application/DslCompiler.php @@ -20,6 +20,24 @@ final class DslCompiler * @return array{sql:string,params:list} */ public function compile(array $ast): array + { + return $this->compileWithProjection($ast, null); + } + + /** + * @param array{sources:list, where:array} $ast + * @return array{sql:string,params:list} + */ + public function compileCountable(array $ast): array + { + return $this->compileWithProjection($ast, '1'); + } + + /** + * @param array{sources:list, where:array} $ast + * @return array{sql:string,params:list} + */ + private function compileWithProjection(array $ast, ?string $selectSql): array { $sources = $ast['sources']; if ($sources === []) { @@ -64,7 +82,7 @@ final class DslCompiler // Always emit source-qualified projection keys so token names remain stable // without any post-query alias fallback. - $selectSql = $this->buildUniqueSelectProjection($sources); + $selectSql = $selectSql ?? $this->buildUniqueSelectProjection($sources); $sql = 'SELECT ' . $selectSql . ' FROM ' . $from; if ($joins !== []) { $sql .= ' ' . implode(' ', $joins); diff --git a/feca_mailshots_plugin/src/Application/MailshotRunService.php b/feca_mailshots_plugin/src/Application/MailshotRunService.php index 5875966..f833e4b 100644 --- a/feca_mailshots_plugin/src/Application/MailshotRunService.php +++ b/feca_mailshots_plugin/src/Application/MailshotRunService.php @@ -54,12 +54,12 @@ final class MailshotRunService public function previewRecipients(int $mailshotId, int $limit = 100): array { try { - [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId); + $limit = max(1, min(500, $limit)); + [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId, $limit); } catch (\Throwable $e) { return ['ok' => false, 'errors' => [$e->getMessage()], 'rows' => []]; } - $limit = max(1, min(500, $limit)); $rows = array_slice($rows, 0, $limit); $out = []; $recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? '')); @@ -112,7 +112,8 @@ final class MailshotRunService public function renderTest(int $mailshotId, int $recipientIndex): array { try { - [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId); + $rowLimit = max(1, min(5000, $recipientIndex + 1)); + [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId, $rowLimit); if (!isset($rows[$recipientIndex])) { return ['ok' => false, 'errors' => ['Selected recipient row was not found.']]; } @@ -775,7 +776,7 @@ final class MailshotRunService } /** @return array{0:array,1:list>} */ - private function loadMailshotAndRows(int $mailshotId): array + private function loadMailshotAndRows(int $mailshotId, int $rowLimit = 5000): array { $mailshot = $this->mailshots->find($mailshotId); if ($mailshot === null) { @@ -793,7 +794,7 @@ final class MailshotRunService throw new \RuntimeException('Data source DSL is empty.'); } - $preview = $this->dataSources->preview($dsl, 5000); + $preview = $this->dataSources->preview($dsl, $rowLimit); if (($preview['errors'] ?? []) !== []) { throw new \RuntimeException('Data source preview failed: ' . implode('; ', $preview['errors'])); } diff --git a/feca_mailshots_plugin/src/Application/MailshotService.php b/feca_mailshots_plugin/src/Application/MailshotService.php index 30ac87e..6ac69f6 100644 --- a/feca_mailshots_plugin/src/Application/MailshotService.php +++ b/feca_mailshots_plugin/src/Application/MailshotService.php @@ -37,11 +37,16 @@ final class MailshotService return $this->mailshots->all(); } + /** @return array|null */ + public function find(int $id): ?array + { + return $this->mailshots->find($id); + } + /** @return list */ public function dataSourceNames(): array { - $rows = $this->queries->all(); - return array_map(static fn(array $r): string => (string) $r['name'], $rows); + return $this->queries->names(); } /** @return list */ diff --git a/feca_mailshots_plugin/src/Infrastructure/PdoDatabaseRouter.php b/feca_mailshots_plugin/src/Infrastructure/PdoDatabaseRouter.php index 7bbcfa9..8363c12 100644 --- a/feca_mailshots_plugin/src/Infrastructure/PdoDatabaseRouter.php +++ b/feca_mailshots_plugin/src/Infrastructure/PdoDatabaseRouter.php @@ -87,8 +87,9 @@ final class PdoDatabaseRouter implements DatabaseRouter private function connect(string $dbName): PDO { + $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $this->host, $this->port, $dbName); return new PDO( - sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $this->host, $this->port, $dbName), + $dsn, $this->user, $this->pass, $this->pdoOptions diff --git a/feca_mailshots_plugin/src/Repository/AttachmentRepository.php b/feca_mailshots_plugin/src/Repository/AttachmentRepository.php index c2525a1..fa8c0c9 100644 --- a/feca_mailshots_plugin/src/Repository/AttachmentRepository.php +++ b/feca_mailshots_plugin/src/Repository/AttachmentRepository.php @@ -9,6 +9,8 @@ use PDO; final class AttachmentRepository { + private const BLOB_READ_CHUNK_BYTES = 524288; + private DatabaseRouter $router; public function __construct(DatabaseRouter $router) @@ -69,7 +71,7 @@ final class AttachmentRepository if ($name === '') { return null; } - $sql = 'SELECT id, name, file_name, mime_type, file_bytes, created_at, updated_at + $sql = 'SELECT id, name, file_name, mime_type, OCTET_LENGTH(file_bytes) AS byte_size, created_at, updated_at FROM mailshot_attachments WHERE LOWER(name) = LOWER(:name) LIMIT 1'; @@ -79,7 +81,14 @@ final class AttachmentRepository if ($row === false) { return null; } - $row['file_bytes'] = (string) ($row['file_bytes'] ?? ''); + $expectedBytes = (int) ($row['byte_size'] ?? 0); + $row['file_bytes'] = $this->readFileBytes((int) ($row['id'] ?? 0), $expectedBytes); + if ($expectedBytes > 0 && strlen($row['file_bytes']) !== $expectedBytes) { + throw new \RuntimeException( + 'Attachment "' . $name . '" was truncated while reading from the database: expected ' + . $expectedBytes . ' bytes, got ' . strlen($row['file_bytes']) . ' bytes.' + ); + } $mimeType = trim((string) ($row['mime_type'] ?? '')); if ($mimeType === '') { $mimeType = $this->inferMimeTypeFromFilename((string) ($row['file_name'] ?? '')); @@ -91,6 +100,34 @@ final class AttachmentRepository return $row; } + private function readFileBytes(int $id, int $expectedBytes): string + { + if ($id <= 0 || $expectedBytes <= 0) { + return ''; + } + + $bytes = ''; + $offset = 0; + $stmt = $this->router->mailshotsPdo()->prepare('SELECT SUBSTRING(file_bytes, :start, :length) AS chunk FROM mailshot_attachments WHERE id = :id'); + + while ($offset < $expectedBytes) { + $length = min(self::BLOB_READ_CHUNK_BYTES, $expectedBytes - $offset); + $stmt->bindValue(':id', $id, PDO::PARAM_INT); + $stmt->bindValue(':start', $offset + 1, PDO::PARAM_INT); + $stmt->bindValue(':length', $length, PDO::PARAM_INT); + $stmt->execute(); + $chunk = $stmt->fetchColumn(); + $stmt->closeCursor(); + if (!is_string($chunk) || $chunk === '') { + break; + } + $bytes .= $chunk; + $offset += strlen($chunk); + } + + return $bytes; + } + private function updateMimeType(int $id, string $mimeType): void { if ($id <= 0 || trim($mimeType) === '') { diff --git a/feca_mailshots_plugin/src/Repository/MailshotQueryRepository.php b/feca_mailshots_plugin/src/Repository/MailshotQueryRepository.php index 2929f4b..15712d4 100644 --- a/feca_mailshots_plugin/src/Repository/MailshotQueryRepository.php +++ b/feca_mailshots_plugin/src/Repository/MailshotQueryRepository.php @@ -19,10 +19,17 @@ final class MailshotQueryRepository /** @return list> */ public function all(): array { - $sql = 'SELECT ID, name, dsl_text, updated_at FROM mailshot_queries ORDER BY name ASC'; + $sql = 'SELECT ID, name, LEFT(COALESCE(dsl_text, \'\'), 1000) AS dsl_text, OCTET_LENGTH(dsl_text) AS dsl_byte_size, updated_at FROM mailshot_queries ORDER BY name ASC'; return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC); } + /** @return list */ + public function names(): array + { + $rows = $this->router->mailshotsPdo()->query('SELECT name FROM mailshot_queries ORDER BY name ASC')->fetchAll(PDO::FETCH_ASSOC); + return array_map(static fn(array $row): string => (string) ($row['name'] ?? ''), $rows); + } + public function find(int $id): ?array { $stmt = $this->router->mailshotsPdo()->prepare('SELECT ID, name, dsl_text, updated_at FROM mailshot_queries WHERE ID = :id'); diff --git a/feca_mailshots_plugin/src/Repository/MailshotRepository.php b/feca_mailshots_plugin/src/Repository/MailshotRepository.php index 13cb508..c9280fc 100644 --- a/feca_mailshots_plugin/src/Repository/MailshotRepository.php +++ b/feca_mailshots_plugin/src/Repository/MailshotRepository.php @@ -21,7 +21,7 @@ final class MailshotRepository public function all(): array { $this->ensureRecipientEmailFieldColumn(); - $sql = 'SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo, RecipientEmailField FROM mailshots ORDER BY Purpose ASC'; + $sql = "SELECT id, Purpose, DataSource, LEFT(COALESCE(Subject, ''), 1000) AS Subject FROM mailshots ORDER BY Purpose ASC"; return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC); } diff --git a/feca_mailshots_plugin/src/Repository/PdfAssetRepository.php b/feca_mailshots_plugin/src/Repository/PdfAssetRepository.php index 001827a..663f025 100644 --- a/feca_mailshots_plugin/src/Repository/PdfAssetRepository.php +++ b/feca_mailshots_plugin/src/Repository/PdfAssetRepository.php @@ -9,6 +9,8 @@ use PDO; final class PdfAssetRepository { + private const BLOB_READ_CHUNK_BYTES = 524288; + private DatabaseRouter $router; public function __construct(DatabaseRouter $router) @@ -30,7 +32,7 @@ final class PdfAssetRepository if ($name === '') { return null; } - $sql = 'SELECT id, name, file_name, mime_type, file_bytes, width_mm, height_mm, justification + $sql = 'SELECT id, name, file_name, mime_type, OCTET_LENGTH(file_bytes) AS byte_size, width_mm, height_mm, justification FROM mailshot_pdf_assets WHERE LOWER(name) = LOWER(:name) LIMIT 1'; @@ -40,10 +42,45 @@ final class PdfAssetRepository if ($row === false) { return null; } - $row['file_bytes'] = (string) ($row['file_bytes'] ?? ''); + $expectedBytes = (int) ($row['byte_size'] ?? 0); + $row['file_bytes'] = $this->readFileBytes((int) ($row['id'] ?? 0), $expectedBytes); + if ($expectedBytes > 0 && strlen($row['file_bytes']) !== $expectedBytes) { + throw new \RuntimeException( + 'PDF asset "' . $name . '" was truncated while reading from the database: expected ' + . $expectedBytes . ' bytes, got ' . strlen($row['file_bytes']) . ' bytes.' + ); + } return $row; } + private function readFileBytes(int $id, int $expectedBytes): string + { + if ($id <= 0 || $expectedBytes <= 0) { + return ''; + } + + $bytes = ''; + $offset = 0; + $stmt = $this->router->mailshotsPdo()->prepare('SELECT SUBSTRING(file_bytes, :start, :length) AS chunk FROM mailshot_pdf_assets WHERE id = :id'); + + while ($offset < $expectedBytes) { + $length = min(self::BLOB_READ_CHUNK_BYTES, $expectedBytes - $offset); + $stmt->bindValue(':id', $id, PDO::PARAM_INT); + $stmt->bindValue(':start', $offset + 1, PDO::PARAM_INT); + $stmt->bindValue(':length', $length, PDO::PARAM_INT); + $stmt->execute(); + $chunk = $stmt->fetchColumn(); + $stmt->closeCursor(); + if (!is_string($chunk) || $chunk === '') { + break; + } + $bytes .= $chunk; + $offset += strlen($chunk); + } + + return $bytes; + } + /** @param array $row */ public function create(array $row): int { diff --git a/tests/integration/test_data_source_list_lightweight.php b/tests/integration/test_data_source_list_lightweight.php new file mode 100644 index 0000000..c770436 --- /dev/null +++ b/tests/integration/test_data_source_list_lightweight.php @@ -0,0 +1,65 @@ + '127.0.0.1', + 'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'), + 'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'), + 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), + 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), + 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), + 'FEN_REMOTE_MYSQL_DB' => Env::require('FEN_REMOTE_MYSQL_DB'), +]; + +$repo = new MailshotQueryRepository(new PdoDatabaseRouter($dbConfig)); +$id = null; +$name = 'ds_list_lightweight_' . gmdate('Ymd_His') . '_' . bin2hex(random_bytes(3)); +$dsl = 'contacts where contacts.id = 1 ' . str_repeat('x', 1800000); + +try { + $id = $repo->create(['name' => $name, 'dsl_text' => $dsl]); + if ($id <= 0) { + fwrite(STDERR, "Data source create did not return an id\n"); + exit(1); + } + + $listedRow = null; + foreach ($repo->all() as $row) { + if ((int) ($row['ID'] ?? 0) === $id) { + $listedRow = $row; + break; + } + } + if (!is_array($listedRow)) { + fwrite(STDERR, "Data source was not returned by list query\n"); + exit(1); + } + if (strlen((string) ($listedRow['dsl_text'] ?? '')) > 1000) { + fwrite(STDERR, "Data source list query should return only a bounded DSL preview\n"); + exit(1); + } + + $fullRow = $repo->find($id); + if (!is_array($fullRow) || (string) ($fullRow['dsl_text'] ?? '') !== $dsl) { + fwrite(STDERR, "Data source find query should fetch full DSL for editing/execution\n"); + exit(1); + } +} finally { + if ($id !== null && $id > 0) { + try { + $repo->delete($id); + } catch (Throwable $e) { + } + } +} + +echo "Data source list lightweight regression test passed\n"; diff --git a/tests/integration/test_mailshot_list_lightweight.php b/tests/integration/test_mailshot_list_lightweight.php new file mode 100644 index 0000000..ddbf7a7 --- /dev/null +++ b/tests/integration/test_mailshot_list_lightweight.php @@ -0,0 +1,83 @@ + '127.0.0.1', + 'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'), + 'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'), + 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), + 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), + 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), + 'FEN_REMOTE_MYSQL_DB' => Env::require('FEN_REMOTE_MYSQL_DB'), +]; + +$repo = new MailshotRepository(new PdoDatabaseRouter($dbConfig)); +$id = null; +$purpose = 'list_lightweight_' . gmdate('Ymd_His') . '_' . bin2hex(random_bytes(3)); +$subject = str_repeat('S', 1800000); +$message = str_repeat('M', 1800000); +$pdfAttachment = str_repeat('P', 1800000); + +try { + $id = $repo->create([ + 'Purpose' => $purpose, + 'DataSource' => 'test_source', + 'CC' => '', + 'BCC' => '', + 'Subject' => $subject, + 'Message' => $message, + 'PDFAttachment' => $pdfAttachment, + 'AttachmentNames' => '[]', + 'PDFFilenameDerivedFrom' => '', + 'ReplyTo' => '', + 'RecipientEmailField' => '', + ]); + if ($id <= 0) { + fwrite(STDERR, "Mailshot create did not return an id\n"); + exit(1); + } + + $listedRow = null; + foreach ($repo->all() as $row) { + if ((int) ($row['id'] ?? 0) === $id) { + $listedRow = $row; + break; + } + } + if (!is_array($listedRow)) { + fwrite(STDERR, "Mailshot was not returned by list query\n"); + exit(1); + } + if (array_key_exists('Message', $listedRow) || array_key_exists('PDFAttachment', $listedRow) || array_key_exists('CC', $listedRow) || array_key_exists('BCC', $listedRow)) { + fwrite(STDERR, "Mailshot list query should not fetch large template fields\n"); + exit(1); + } + if (strlen((string) ($listedRow['Subject'] ?? '')) > 1000) { + fwrite(STDERR, "Mailshot list query should only fetch a bounded subject preview\n"); + exit(1); + } + + $fullRow = $repo->find($id); + if (!is_array($fullRow) || (string) ($fullRow['Subject'] ?? '') !== $subject || (string) ($fullRow['Message'] ?? '') !== $message || (string) ($fullRow['PDFAttachment'] ?? '') !== $pdfAttachment) { + fwrite(STDERR, "Mailshot find query should fetch full template fields for editing/sending\n"); + exit(1); + } +} finally { + if ($id !== null && $id > 0) { + try { + $repo->delete($id); + } catch (Throwable $e) { + } + } +} + +echo "Mailshot list lightweight regression test passed\n"; diff --git a/tests/integration/test_review_recipients_paging.php b/tests/integration/test_review_recipients_paging.php new file mode 100644 index 0000000..d0eee42 --- /dev/null +++ b/tests/integration/test_review_recipients_paging.php @@ -0,0 +1,67 @@ + '127.0.0.1', + 'MYSQL_PORT' => (string) (getenv('MYSQL_TUNNEL_LOCAL_PORT') ?: '13306'), + 'MYSQL_USER' => Env::require('REMOTE_MYSQL_USER'), + 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), + 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), + 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), + 'FEN_REMOTE_MYSQL_DB' => Env::require('FEN_REMOTE_MYSQL_DB'), +]; + +$router = new PdoDatabaseRouter($dbConfig); +$queries = new MailshotQueryRepository($router); +$mailshots = new MailshotRepository($router); +$metadata = new DatabaseSourceMetadataProvider($router); +$service = new DataSourceService( + $queries, + $router, + new DslParser(), + new DslValidator($metadata), + new DslCompiler($metadata), + $metadata, + $mailshots +); + +$first = $service->review('contacts', 3, 0); +$second = $service->review('contacts', 3, 3); + +foreach ([$first, $second] as $result) { + if (($result['errors'] ?? []) !== []) { + fwrite(STDERR, 'Review recipients paging returned errors: ' . implode('; ', (array) $result['errors']) . "\n"); + exit(1); + } + if ((int) ($result['returned_count'] ?? 0) > 3 || count((array) ($result['rows'] ?? [])) > 3) { + fwrite(STDERR, "Review recipients paging exceeded the requested limit\n"); + exit(1); + } +} + +if ((int) ($first['count'] ?? 0) !== (int) ($second['count'] ?? -1)) { + fwrite(STDERR, "Review recipients paging should preserve the total count across pages\n"); + exit(1); +} + +if ((int) ($second['offset'] ?? -1) !== 3) { + fwrite(STDERR, "Review recipients second page should report the requested offset\n"); + exit(1); +} + +echo "Review recipients paging regression test passed\n"; diff --git a/tests/unit/test_dsl_pipeline.php b/tests/unit/test_dsl_pipeline.php index 5553b83..01b6436 100644 --- a/tests/unit/test_dsl_pipeline.php +++ b/tests/unit/test_dsl_pipeline.php @@ -124,6 +124,13 @@ foreach ($cases as $case) { if (strpos($compiled['sql'], 'SELECT ') !== 0) { $failures[] = ['dsl' => $case['dsl'], 'errors' => ['Compilation did not produce SELECT']]; } + $countable = $compiler->compileCountable($ast); + if (strpos($countable['sql'], 'SELECT 1 FROM ') !== 0) { + $failures[] = ['dsl' => $case['dsl'], 'errors' => ['Count compilation did not produce narrow SELECT']]; + } + if (strpos($countable['sql'], ' AS `') !== false) { + $failures[] = ['dsl' => $case['dsl'], 'errors' => ['Count compilation should not project source fields']]; + } } }