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 '';
- $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']];
+ }
}
}