Chasing memory allocation bugs

This commit is contained in:
Adrian Stephens 2026-07-18 17:50:24 +01:00
parent 69b52c2dd9
commit 8fc530c51b
20 changed files with 559 additions and 151 deletions

BIN
dist/feca_mailshots_plugin-1.1.17.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.1.18.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.1.19.zip vendored Normal file

Binary file not shown.

View File

@ -3,7 +3,7 @@
* Plugin Name: FECA Mailshots * Plugin Name: FECA Mailshots
* Plugin URI: https://fenedge.co.uk/ * Plugin URI: https://fenedge.co.uk/
* Description: FECA mailshots plugin. * Description: FECA mailshots plugin.
* Version: 1.1.11 * Version: 1.1.20
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Author: FECA * Author: FECA

View File

@ -88,10 +88,11 @@ final class DataSourcesAdminPage
$editId = $draftId; $editId = $draftId;
} }
$editItem = null; $editItem = null;
foreach ($items as $row) { if ($editId > 0) {
if ((int) ($row['ID'] ?? 0) === $editId) { try {
$editItem = $row; $editItem = $this->service()->get($editId);
break; } catch (\Throwable $e) {
$editItem = null;
} }
} }
$name = is_array($editItem) ? (string) ($editItem['name'] ?? '') : ''; $name = is_array($editItem) ? (string) ($editItem['name'] ?? '') : '';

View File

@ -74,10 +74,11 @@ final class MailshotsAdminPage
$editId = $draftId; $editId = $draftId;
} }
$editItem = null; $editItem = null;
foreach ($items as $row) { if ($editId > 0) {
if ((int) ($row['id'] ?? 0) === $editId) { try {
$editItem = $row; $editItem = $this->service()->find($editId);
break; } catch (\Throwable $e) {
$editItem = null;
} }
} }

View File

@ -13,6 +13,8 @@ final class ReviewRecipientsAdminPage
private const CAPABILITY = 'edit_pages'; private const CAPABILITY = 'edit_pages';
private const PAGE_SLUG = 'feca-mailshots-review-recipients'; private const PAGE_SLUG = 'feca-mailshots-review-recipients';
private const DEFAULT_PAGE_SIZE = 200;
private const MAX_PAGE_SIZE = 500;
/** @var callable(): DataSourceService */ /** @var callable(): DataSourceService */
private $serviceFactory; private $serviceFactory;
@ -43,26 +45,13 @@ final class ReviewRecipientsAdminPage
return; return;
} }
$service = $this->service(); $sources = $this->service()->list();
$sources = $service->list();
$selectedSource = trim((string) ($this->wp->requestParam('data_source', '') ?? '')); $selectedSource = trim((string) ($this->wp->requestParam('data_source', '') ?? ''));
$sourceNames = [];
$initialRows = []; foreach ($sources as $source) {
$initialColumns = []; $name = trim((string) ($source['name'] ?? ''));
$initialCount = 0; if ($name !== '') {
$initialErrors = []; $sourceNames[] = $name;
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));
}
} }
} }
@ -76,18 +65,14 @@ final class ReviewRecipientsAdminPage
echo '<label for="rr_data_source"><strong>Data Source</strong></label>'; echo '<label for="rr_data_source"><strong>Data Source</strong></label>';
echo '<select id="rr_data_source" class="feca-minw-220">'; echo '<select id="rr_data_source" class="feca-minw-220">';
echo '<option value="">Select data source</option>'; echo '<option value="">Select data source</option>';
foreach ($sources as $source) { foreach ($sourceNames as $name) {
$name = trim((string) ($source['name'] ?? ''));
if ($name === '') {
continue;
}
$selected = $name === $selectedSource ? ' selected' : ''; $selected = $name === $selectedSource ? ' selected' : '';
echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($name) . '</option>'; echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($name) . '</option>';
} }
echo '</select></div>'; echo '</select></div>';
echo '<div class="feca-control feca-control-min-360">'; echo '<div class="feca-control feca-control-min-360">';
echo '<label for="rr_filter"><strong>Filter by</strong></label>'; echo '<label for="rr_filter"><strong>Filter by</strong></label>';
echo '<input id="rr_filter" class="regular-text feca-minw-240" type="text" placeholder="Matches any field">'; echo '<input id="rr_filter" class="regular-text feca-minw-240" type="text" placeholder="Matches loaded fields">';
echo '</div>'; echo '</div>';
echo '<div class="feca-control feca-control-min-260">'; echo '<div class="feca-control feca-control-min-260">';
echo '<label for="rr_sort_by"><strong>Sort by</strong></label>'; echo '<label for="rr_sort_by"><strong>Sort by</strong></label>';
@ -99,96 +84,79 @@ final class ReviewRecipientsAdminPage
echo '</div>'; echo '</div>';
echo '</div></div>'; echo '</div></div>';
echo '<div id="rr_status" class="feca-banner">'; echo '<div id="rr_status" class="feca-banner">Select a data source to load recipients.</div>';
echo 'Select a data source to load recipients.';
echo '</div>';
echo '<div id="rr_error" class="feca-banner feca-banner-error feca-hidden"></div>'; echo '<div id="rr_error" class="feca-banner feca-banner-error feca-hidden"></div>';
echo '<div class="feca-scroll-frame">'; echo '<div class="feca-scroll-frame"><div id="rr_scroll" class="feca-scroll-pane">';
echo '<div id="rr_scroll" class="feca-scroll-pane">';
echo '<table class="widefat striped feca-table-wide" id="rr_table">'; echo '<table class="widefat striped feca-table-wide" id="rr_table">';
echo '<colgroup id="rr_cols"></colgroup>'; echo '<colgroup id="rr_cols"></colgroup>';
echo '<thead><tr id="rr_head_row"><th>No recipients loaded.</th></tr></thead>'; echo '<thead><tr id="rr_head_row"><th>No recipients loaded.</th></tr></thead>';
echo '<tbody id="rr_body"></tbody></table>'; echo '<tbody id="rr_body"></tbody></table>';
echo '</div>'; echo '</div></div>';
echo '</div>'; echo '<p class="feca-button-row"><button type="button" class="button" id="rr_load_more">Load More</button></p>';
$sourceDslMap = [];
foreach ($sources as $source) {
$name = trim((string) ($source['name'] ?? ''));
if ($name === '') {
continue;
}
$sourceDslMap[$name] = trim((string) ($source['dsl_text'] ?? ''));
}
echo '<script>'; echo '<script>';
echo 'window.fecaReviewRecipientsConfig = ' . json_encode([ echo 'window.fecaReviewRecipientsConfig = ' . json_encode([
'api' => $this->wp->adminUrl('admin-post.php?action=feca_mailshots_review_recipients_api'), 'api' => $this->wp->adminUrl('admin-post.php?action=feca_mailshots_review_recipients_api'),
'pageSlug' => self::PAGE_SLUG, 'pageSlug' => self::PAGE_SLUG,
'selectedSource' => $selectedSource, 'selectedSource' => $selectedSource,
'sources' => array_values(array_keys($sourceDslMap)), 'sources' => $sourceNames,
'sourceDslMap' => $sourceDslMap, 'pageSize' => self::DEFAULT_PAGE_SIZE,
'initial' => [
'ok' => $initialErrors === [],
'errors' => $initialErrors,
'rows' => $initialRows,
'columns' => $initialColumns,
'count' => $initialCount,
'source' => $selectedSource,
],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';'; ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';';
echo '(function(){'; echo <<<'JS'
echo 'var cfg=window.fecaReviewRecipientsConfig||{};'; (function(){
echo 'var sourceSel=document.getElementById("rr_data_source");'; var cfg=window.fecaReviewRecipientsConfig||{};
echo 'var filterInput=document.getElementById("rr_filter");'; var sourceSel=document.getElementById("rr_data_source");
echo 'var sortSel=document.getElementById("rr_sort_by");'; var filterInput=document.getElementById("rr_filter");
echo 'var dirSel=document.getElementById("rr_sort_direction");'; var sortSel=document.getElementById("rr_sort_by");
echo 'var statusEl=document.getElementById("rr_status");'; var dirSel=document.getElementById("rr_sort_direction");
echo 'var errEl=document.getElementById("rr_error");'; var statusEl=document.getElementById("rr_status");
echo 'var scrollWrap=document.getElementById("rr_scroll");'; var errEl=document.getElementById("rr_error");
echo 'var colsEl=document.getElementById("rr_cols");'; var colsEl=document.getElementById("rr_cols");
echo 'var headRow=document.getElementById("rr_head_row");'; var headRow=document.getElementById("rr_head_row");
echo 'var bodyEl=document.getElementById("rr_body");'; var bodyEl=document.getElementById("rr_body");
echo 'var cache={};'; var loadMoreBtn=document.getElementById("rr_load_more");
echo 'var storagePrefix="fecaReviewRecipientsSelection::";'; var storagePrefix="fecaReviewRecipientsSelection::";
echo 'var state={source:"",filter:"",sortBy:"",sortDirection:"asc",selectedKeys:{}};'; var state={source:"",filter:"",sortBy:"",sortDirection:"asc",selectedKeys:{},rows:[],columns:[],count:0,offset:0,hasMore:false,loading:false,errors:[]};
echo 'function text(v){if(v===null||v===undefined){return "";}if(typeof v==="string"){return v;}if(typeof v==="number"||typeof v==="boolean"){return String(v);}try{return JSON.stringify(v);}catch(_){return String(v);}}'; function text(v){if(v===null||v===undefined){return "";}if(typeof v==="string"){return v;}if(typeof v==="number"||typeof v==="boolean"){return String(v);}try{return JSON.stringify(v);}catch(_){return String(v);}}
echo 'function clearErr(){errEl.classList.add("feca-hidden");errEl.textContent="";}'; function clearErr(){errEl.classList.add("feca-hidden");errEl.textContent="";}
echo 'function showErr(msg){errEl.classList.remove("feca-hidden");errEl.textContent=msg||"Unknown error";}'; function showErr(msg){errEl.classList.remove("feca-hidden");errEl.textContent=msg||"Unknown error";}
echo 'function status(msg){statusEl.textContent=msg||"";}'; function status(msg){statusEl.textContent=msg||"";}
echo 'function setColumnWidths(cols){var widthPx=170;var c="<col style=\\"width:44px;min-width:44px;max-width:44px\\">";for(var i=0;i<cols.length;i++){c+="<col style=\\"width:"+widthPx+"px;min-width:"+widthPx+"px;max-width:"+widthPx+"px\\">";}colsEl.innerHTML=c;}'; function setColumnWidths(cols){var widthPx=170;var c="<col style=\"width:44px;min-width:44px;max-width:44px\">";for(var i=0;i<cols.length;i++){c+="<col style=\"width:"+widthPx+"px;min-width:"+widthPx+"px;max-width:"+widthPx+"px\">";}colsEl.innerHTML=c;}
echo 'function rebuildSortColumns(cols,keep){sortSel.innerHTML="";if(!cols||!cols.length){var o=document.createElement("option");o.value="";o.textContent="(no fields)";sortSel.appendChild(o);state.sortBy="";return;}cols.forEach(function(c,idx){var o=document.createElement("option");o.value=c;o.textContent=c;if((keep&&c===keep)||(!keep&&idx===0)){o.selected=true;state.sortBy=c;}sortSel.appendChild(o);});}'; function sourceStorageKey(src){return storagePrefix+String(src||"");}
echo 'function isIdField(name){var n=String(name||"").trim().toLowerCase();if(!n){return false;}return n==="id"||n.endsWith(".id")||n.endsWith("_id")||n==="accountid"||n.endsWith(".accountid");}'; function loadSelection(src){if(!src){return {};}try{var raw=sessionStorage.getItem(sourceStorageKey(src));if(!raw){return {};}var parsed=JSON.parse(raw);if(parsed&&typeof parsed==="object"){return parsed;}}catch(_){ }return {};}
echo 'function normalizeColumns(rawCols,rows){var cols=(rawCols||[]).slice();if(cols.length===0&&rows&&rows.length){var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});}var seenOut={};var out=[];cols.forEach(function(c){var key=String(c||"").trim();if(!key||isIdField(key)){return;}var lk=key.toLowerCase();if(seenOut[lk]){return;}seenOut[lk]=1;out.push(key);});out.sort(function(a,b){return a.localeCompare(b,undefined,{sensitivity:"base"});});return out;}'; function saveSelection(){if(!state.source){return;}try{sessionStorage.setItem(sourceStorageKey(state.source),JSON.stringify(state.selectedKeys||{}));}catch(_){ }}
echo 'function dedupeEquivalentColumns(cols,rows){if(!cols||cols.length<2||!rows||rows.length===0){return cols||[];}var keep=[];var signatures={};var limit=Math.min(rows.length,50);for(var i=0;i<cols.length;i++){var col=cols[i];var sig=[];for(var r=0;r<limit;r++){var row=rows[r]||{};sig.push(text(row[col]));}var sigKey=sig.join("\\u241f").toLowerCase();if(sigKey!==""&&signatures[sigKey]){continue;}signatures[sigKey]=col;keep.push(col);}return keep;}'; function isIdField(name){var n=String(name||"").trim().toLowerCase();return n==="id"||n.endsWith(".id")||n.endsWith("_id")||n==="accountid"||n.endsWith(".accountid");}
echo 'function rowMatches(row,needle,cols){if(!needle){return true;}var n=needle.toLowerCase();for(var i=0;i<cols.length;i++){var k=cols[i];if(text((row||{})[k]).toLowerCase().indexOf(n)!==-1){return true;}}return false;}'; function normalizeColumns(rawCols,rows){var cols=(rawCols||[]).slice();if(cols.length===0&&rows&&rows.length){var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});}var seenOut={};var out=[];cols.forEach(function(c){var key=String(c||"").trim();if(!key||isIdField(key)){return;}var lk=key.toLowerCase();if(seenOut[lk]){return;}seenOut[lk]=1;out.push(key);});out.sort(function(a,b){return a.localeCompare(b,undefined,{sensitivity:"base"});});return out;}
echo 'function headerParts(name){var n=String(name||"");var idx=n.lastIndexOf(".");if(idx<=0||idx>=n.length-1){return {prefix:"",field:n};}return {prefix:n.substring(0,idx),field:n.substring(idx+1)};}'; function dedupeEquivalentColumns(cols,rows){if(!cols||cols.length<2||!rows||rows.length===0){return cols||[];}var keep=[];var signatures={};var limit=Math.min(rows.length,50);for(var i=0;i<cols.length;i++){var col=cols[i];var sig=[];for(var r=0;r<limit;r++){sig.push(text((rows[r]||{})[col]));}var sigKey=sig.join("\u241f").toLowerCase();if(sigKey!==""&&signatures[sigKey]){continue;}signatures[sigKey]=col;keep.push(col);}return keep;}
echo 'function sourceStorageKey(src){return storagePrefix+String(src||"");}'; function rowMatches(row,needle,cols){if(!needle){return true;}var n=needle.toLowerCase();for(var i=0;i<cols.length;i++){var k=cols[i];if(text((row||{})[k]).toLowerCase().indexOf(n)!==-1){return true;}}return false;}
echo 'function loadSelection(src){if(!src){return {};}try{var raw=sessionStorage.getItem(sourceStorageKey(src));if(!raw){return {};}var parsed=JSON.parse(raw);if(parsed&&typeof parsed==="object"){return parsed;}}catch(_){ }return {};}'; function headerParts(name){var n=String(name||"");var idx=n.lastIndexOf(".");if(idx<=0||idx>=n.length-1){return {prefix:"",field:n};}return {prefix:n.substring(0,idx),field:n.substring(idx+1)};}
echo 'function saveSelection(){if(!state.source){return;}try{sessionStorage.setItem(sourceStorageKey(state.source),JSON.stringify(state.selectedKeys||{}));}catch(_){ }}'; function detectKey(row){var r=row||{};if(r.__rr_index!==undefined){return "row_index:"+String(r.__rr_index);}return "";}
echo 'function detectKey(row){var r=row||{};if(r.__rr_index!==undefined){return "row_index:"+String(r.__rr_index);}return "";}'; function rebuildSortColumns(cols,keep){sortSel.innerHTML="";if(!cols||!cols.length){var o=document.createElement("option");o.value="";o.textContent="(no fields)";sortSel.appendChild(o);state.sortBy="";return;}cols.forEach(function(c,idx){var o=document.createElement("option");o.value=c;o.textContent=c;if((keep&&c===keep)||(!keep&&idx===0)){o.selected=true;state.sortBy=c;}sortSel.appendChild(o);});}
echo 'function renderTable(){clearErr();var src=state.source;if(!src){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Select a data source to load recipients.");rebuildSortColumns([], "");return;}var payload=cache[src];if(!payload){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Loading recipients...");return;}if(payload.errors&&payload.errors.length){setColumnWidths([]);showErr(payload.errors.join("; "));headRow.innerHTML="<th>Unable to render recipients.</th>";bodyEl.innerHTML="";status("Load failed.");rebuildSortColumns([], "");return;}var rows=(payload.rows||[]).map(function(r,i){var out=(r&&typeof r==="object")?Object.assign({},r):{};out.__rr_index=i;return out;});var cols=normalizeColumns(payload.columns||[],rows);cols=dedupeEquivalentColumns(cols,rows);if(cols.length===0){setColumnWidths([]);headRow.innerHTML="<th>No fields</th>";bodyEl.innerHTML="";status("Rows: 0 | Total from query: "+(payload.count||0)+" | Selected: 0");rebuildSortColumns([], "");return;}if(state.sortBy&&cols.indexOf(state.sortBy)===-1){state.sortBy="";}rebuildSortColumns(cols,state.sortBy);var filtered=rows.filter(function(r){return rowMatches(r||{},state.filter,cols);});if(state.sortBy){filtered.sort(function(a,b){var l=text((a||{})[state.sortBy]).toLowerCase();var r=text((b||{})[state.sortBy]).toLowerCase();var cmp=l<r?-1:(l>r?1:0);return state.sortDirection==="desc"?-cmp:cmp;});}'; function updateLoadMore(){if(!loadMoreBtn){return;}loadMoreBtn.disabled=state.loading||!state.hasMore||!state.source;loadMoreBtn.style.display=state.source&&state.hasMore?"inline-block":"none";}
echo 'setColumnWidths(cols);headRow.innerHTML="";var selHead=document.createElement("th");selHead.className="feca-rr-head-cell";selHead.innerHTML="<input id=\\"rr_select_all\\" type=\\"checkbox\\" title=\\"Select/Deselect all visible rows\\">";headRow.appendChild(selHead);cols.forEach(function(c){var th=document.createElement("th");th.className="feca-rr-head-cell";var p=headerParts(c);if(p.prefix){th.innerHTML="<div class=\\"feca-rr-head-wrap\\"><span class=\\"feca-rr-head-prefix\\">"+p.prefix+"</span><span class=\\"feca-rr-head-field\\">"+p.field+"</span></div>";}else{th.innerHTML="<div class=\\"feca-rr-head-wrap\\"><span class=\\"feca-rr-head-field\\">"+p.field+"</span></div>";}headRow.appendChild(th);});bodyEl.innerHTML="";filtered.forEach(function(r){var tr=document.createElement("tr");var rowKey=detectKey(r);tr.setAttribute("data-row-key",rowKey);var selTd=document.createElement("td");selTd.className="feca-rr-data-cell";var checked=!!(rowKey&&state.selectedKeys[rowKey]);selTd.innerHTML="<input class=\\"rr-row-select\\" type=\\"checkbox\\" data-key=\\""+String(rowKey).replace(/"/g,"&quot;")+"\\""+(checked?" checked":"")+">";tr.appendChild(selTd);cols.forEach(function(c){var td=document.createElement("td");var v=text((r||{})[c]);td.className="feca-rr-data-cell";td.title=v;td.textContent=v;tr.appendChild(td);});bodyEl.appendChild(tr);});'; function renderTable(){if(!state.source){clearErr();setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Select a data source to load recipients.");rebuildSortColumns([], "");updateLoadMore();return;}if(state.errors&&state.errors.length){showErr(state.errors.join("; "));setColumnWidths([]);headRow.innerHTML="<th>Unable to render recipients.</th>";bodyEl.innerHTML="";status("Load failed.");rebuildSortColumns([], "");updateLoadMore();return;}clearErr();if(state.loading&&state.rows.length===0){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Loading recipients...");updateLoadMore();return;}var rows=state.rows.slice();var cols=dedupeEquivalentColumns(normalizeColumns(state.columns,rows),rows);if(cols.length===0){setColumnWidths([]);headRow.innerHTML="<th>No fields</th>";bodyEl.innerHTML="";status("Rows loaded: "+rows.length+" | Total from query: "+state.count+" | Selected: 0");rebuildSortColumns([], "");updateLoadMore();return;}if(state.sortBy&&cols.indexOf(state.sortBy)===-1){state.sortBy="";}rebuildSortColumns(cols,state.sortBy);var filtered=rows.filter(function(r){return rowMatches(r||{},state.filter,cols);});if(state.sortBy){filtered.sort(function(a,b){var l=text((a||{})[state.sortBy]).toLowerCase();var r=text((b||{})[state.sortBy]).toLowerCase();var cmp=l<r?-1:(l>r?1:0);return state.sortDirection==="desc"?-cmp:cmp;});}setColumnWidths(cols);headRow.innerHTML="";var selHead=document.createElement("th");selHead.className="feca-rr-head-cell";selHead.innerHTML="<input id=\"rr_select_all\" type=\"checkbox\" title=\"Select/Deselect all visible rows\">";headRow.appendChild(selHead);cols.forEach(function(c){var th=document.createElement("th");th.className="feca-rr-head-cell";var p=headerParts(c);var wrap=document.createElement("div");wrap.className="feca-rr-head-wrap";if(p.prefix){var pre=document.createElement("span");pre.className="feca-rr-head-prefix";pre.textContent=p.prefix;wrap.appendChild(pre);}var field=document.createElement("span");field.className="feca-rr-head-field";field.textContent=p.field;wrap.appendChild(field);th.appendChild(wrap);headRow.appendChild(th);});bodyEl.innerHTML="";filtered.forEach(function(r){var tr=document.createElement("tr");var rowKey=detectKey(r);tr.setAttribute("data-row-key",rowKey);var selTd=document.createElement("td");selTd.className="feca-rr-data-cell";var cb=document.createElement("input");cb.className="rr-row-select";cb.type="checkbox";cb.setAttribute("data-key",rowKey);cb.checked=!!(rowKey&&state.selectedKeys[rowKey]);selTd.appendChild(cb);tr.appendChild(selTd);cols.forEach(function(c){var td=document.createElement("td");var v=text((r||{})[c]);td.className="feca-rr-data-cell";td.title=v;td.textContent=v;tr.appendChild(td);});bodyEl.appendChild(tr);});var allKeys=rows.map(detectKey).filter(function(k){return !!k;});var selectedInSource=0;allKeys.forEach(function(k){if(state.selectedKeys[k]){selectedInSource++;}});var visibleKeys=filtered.map(detectKey).filter(function(k){return !!k;});var allVisibleSelected=visibleKeys.length>0&&visibleKeys.every(function(k){return !!state.selectedKeys[k];});var selAll=document.getElementById("rr_select_all");if(selAll){selAll.checked=allVisibleSelected;selAll.indeterminate=visibleKeys.length>0&&!allVisibleSelected&&visibleKeys.some(function(k){return !!state.selectedKeys[k];});}status("Rows loaded: "+rows.length+" | Visible: "+filtered.length+" | Total from query: "+state.count+" | Selected: "+selectedInSource+(state.hasMore?" | More rows available":"")+(state.filter?" | Filter: "+state.filter:""));saveSelection();updateLoadMore();}
echo 'var allKeys=rows.map(detectKey).filter(function(k){return !!k;});var selectedInSource=0;allKeys.forEach(function(k){if(state.selectedKeys[k]){selectedInSource++;}});var visibleKeys=filtered.map(detectKey).filter(function(k){return !!k;});var allVisibleSelected=visibleKeys.length>0&&visibleKeys.every(function(k){return !!state.selectedKeys[k];});var selAll=document.getElementById("rr_select_all");if(selAll){selAll.checked=allVisibleSelected;selAll.indeterminate=visibleKeys.length>0&&!allVisibleSelected&&visibleKeys.some(function(k){return !!state.selectedKeys[k];});}status("Rows: "+filtered.length+" | Total from query: "+(payload.count||0)+" | Selected: "+selectedInSource+(state.filter?(" | Filter: "+state.filter):""));saveSelection();}'; function applyPayload(sourceName,j,append){var offset=parseInt(j.offset||0,10);var rows=(j.rows||[]).map(function(r,i){var out=(r&&typeof r==="object")?Object.assign({},r):{};out.__rr_index=offset+i;return out;});state.source=sourceName;state.count=parseInt(j.count||0,10)||0;state.offset=offset+rows.length;state.hasMore=!!j.has_more;state.columns=append?Array.from(new Set(state.columns.concat(j.columns||[]))):(j.columns||[]);state.rows=append?state.rows.concat(rows):rows;}
echo 'function fetchSource(sourceName){if(!sourceName){state.source="";renderTable();return;}if(cache[sourceName]){state.source=sourceName;renderTable();return;}clearErr();status("Loading recipients...");var url=(cfg.api||"")+""+(String(cfg.api||"").indexOf("?")===-1?"?":"&")+"op=load&data_source="+encodeURIComponent(sourceName);fetch(url,{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("API "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){var errors=(j&&j.errors)||[(j&&j.error)||"Unknown API error"];cache[sourceName]={errors:errors,rows:[],columns:[],count:0};}else{cache[sourceName]={errors:[],rows:(j.rows||[]),columns:(j.columns||[]),count:(j.count||0)};}state.source=sourceName;renderTable();}).catch(function(e){cache[sourceName]={errors:[(e&&e.message)||"Request failed"],rows:[],columns:[],count:0};state.source=sourceName;renderTable();});}'; function fetchSource(sourceName,append){if(!sourceName){state.source="";state.errors=[];state.rows=[];state.columns=[];state.count=0;state.offset=0;state.hasMore=false;renderTable();return;}clearErr();state.errors=[];state.loading=true;status(append?"Loading more recipients...":"Loading recipients...");updateLoadMore();var offset=append?state.offset:0;var limit=parseInt(cfg.pageSize||200,10)||200;var url=(cfg.api||"")+""+(String(cfg.api||"").indexOf("?")===-1?"?":"&")+"op=load&data_source="+encodeURIComponent(sourceName)+"&limit="+encodeURIComponent(String(limit))+"&offset="+encodeURIComponent(String(offset));fetch(url,{credentials:"same-origin"}).then(function(r){return r.text().then(function(raw){var j=null;try{j=JSON.parse(raw||"{}");}catch(_){j=null;}if(!r.ok){var errors=(j&&j.errors)||[(j&&j.error)||("API "+r.status)];return {ok:false,errors:errors,rows:[],columns:[],count:0};}if(j===null){return {ok:false,errors:["API returned non-JSON response."],rows:[],columns:[],count:0};}return j;});}).then(function(j){if(!j||j.ok===false){state.errors=(j&&j.errors)||[(j&&j.error)||"Unknown API error"];state.rows=[];state.columns=[];state.count=0;state.offset=0;state.hasMore=false;}else{applyPayload(sourceName,j,!!append);}state.loading=false;renderTable();}).catch(function(e){state.loading=false;state.errors=[(e&&e.message)||"Request failed"];state.rows=[];state.columns=[];state.count=0;state.offset=0;state.hasMore=false;renderTable();});}
echo 'function updateUrlSource(sourceName){var url=new URL(window.location.href);url.searchParams.set("page",cfg.pageSlug||"feca-mailshots-review-recipients");if(sourceName){url.searchParams.set("data_source",sourceName);}else{url.searchParams.delete("data_source");}window.history.replaceState({}, "", url.toString());}'; function updateUrlSource(sourceName){var url=new URL(window.location.href);url.searchParams.set("page",cfg.pageSlug||"feca-mailshots-review-recipients");if(sourceName){url.searchParams.set("data_source",sourceName);}else{url.searchParams.delete("data_source");}window.history.replaceState({}, "", url.toString());}
echo 'sourceSel.addEventListener("change",function(){var s=String(sourceSel.value||"").trim();state.source=s;state.selectedKeys=loadSelection(s);updateUrlSource(s);fetchSource(s);});'; sourceSel.addEventListener("change",function(){var s=String(sourceSel.value||"").trim();state.source=s;state.errors=[];state.selectedKeys=loadSelection(s);state.rows=[];state.columns=[];state.count=0;state.offset=0;state.hasMore=false;updateUrlSource(s);fetchSource(s,false);});
echo 'filterInput.addEventListener("input",function(){state.filter=String(filterInput.value||"").trim();renderTable();});'; filterInput.addEventListener("input",function(){state.filter=String(filterInput.value||"").trim();renderTable();});
echo 'sortSel.addEventListener("change",function(){state.sortBy=String(sortSel.value||"").trim();renderTable();});'; sortSel.addEventListener("change",function(){state.sortBy=String(sortSel.value||"").trim();renderTable();});
echo 'dirSel.addEventListener("change",function(){state.sortDirection=String(dirSel.value||"asc")==="desc"?"desc":"asc";renderTable();});'; dirSel.addEventListener("change",function(){state.sortDirection=String(dirSel.value||"asc")==="desc"?"desc":"asc";renderTable();});
echo 'headRow.addEventListener("change",function(ev){var t=ev.target;if(!t||t.id!=="rr_select_all"){return;}var checked=!!t.checked;var nodes=bodyEl.querySelectorAll("input.rr-row-select[data-key]");nodes.forEach(function(node){var key=String(node.getAttribute("data-key")||"");if(!key){return;}if(checked){state.selectedKeys[key]=true;}else{delete state.selectedKeys[key];}node.checked=checked;});renderTable();});'; if(loadMoreBtn){loadMoreBtn.addEventListener("click",function(){if(state.source&&state.hasMore&&!state.loading){fetchSource(state.source,true);}});}
echo 'bodyEl.addEventListener("change",function(ev){var t=ev.target;if(!t||!t.classList||!t.classList.contains("rr-row-select")){return;}var key=String(t.getAttribute("data-key")||"");if(!key){return;}if(t.checked){state.selectedKeys[key]=true;}else{delete state.selectedKeys[key];}renderTable();});'; headRow.addEventListener("change",function(ev){var t=ev.target;if(!t||t.id!=="rr_select_all"){return;}var checked=!!t.checked;var nodes=bodyEl.querySelectorAll("input.rr-row-select[data-key]");nodes.forEach(function(node){var key=String(node.getAttribute("data-key")||"");if(!key){return;}if(checked){state.selectedKeys[key]=true;}else{delete state.selectedKeys[key];}node.checked=checked;});renderTable();});
echo 'state.sortDirection="asc";dirSel.value="asc";'; bodyEl.addEventListener("change",function(ev){var t=ev.target;if(!t||!t.classList||!t.classList.contains("rr-row-select")){return;}var key=String(t.getAttribute("data-key")||"");if(!key){return;}if(t.checked){state.selectedKeys[key]=true;}else{delete state.selectedKeys[key];}renderTable();});
echo 'if(cfg.initial&&cfg.initial.source&&cfg.initial.ok){cache[cfg.initial.source]={errors:[],rows:(cfg.initial.rows||[]),columns:(cfg.initial.columns||[]),count:(cfg.initial.count||0)};}'; state.sortDirection="asc";dirSel.value="asc";
echo 'if(cfg.initial&&cfg.initial.source&&cfg.initial.errors&&cfg.initial.errors.length){cache[cfg.initial.source]={errors:cfg.initial.errors,rows:[],columns:[],count:0};}'; var startSource=String((cfg.selectedSource||sourceSel.value||"")).trim();state.source=startSource;state.selectedKeys=loadSelection(startSource);if(startSource){fetchSource(startSource,false);}else{renderTable();}
echo 'var startSource=String((cfg.selectedSource||sourceSel.value||"")).trim();state.source=startSource;'; })();
echo 'state.selectedKeys=loadSelection(startSource);'; JS;
echo 'if(startSource){fetchSource(startSource);}else{renderTable();}';
echo '})();';
echo '</script>'; echo '</script>';
echo '</div>';
} }
public function handleApi(): void public function handleApi(): void
{ {
$stage = 'initializing review recipients API';
$this->registerFatalJsonTrap('review recipients API', $stage);
if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) { if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) {
return; return;
} }
@ -205,42 +173,111 @@ final class ReviewRecipientsAdminPage
return; return;
} }
$sources = $this->service()->list(); $limit = max(1, min(self::MAX_PAGE_SIZE, $this->requestInt('limit', self::DEFAULT_PAGE_SIZE)));
$dsl = $this->dslForSource($sources, $sourceName); $offset = max(0, $this->requestInt('offset', 0));
if ($dsl === '') {
$this->wp->sendJson(['ok' => false, 'errors' => ['Selected data source was not found.']], 404);
return;
}
try { 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'] ?? []))); $errors = array_values(array_map('strval', (array) ($result['errors'] ?? [])));
if ($errors !== []) { if ($errors !== []) {
$this->wp->sendJson(['ok' => false, 'errors' => $errors], 400); $this->wp->sendJson(['ok' => false, 'errors' => $errors], 400);
return; return;
} }
$this->wp->sendJson([
$stage = 'encoding recipient rows';
$payload = [
'ok' => true, 'ok' => true,
'source' => $sourceName, 'source' => $sourceName,
'rows' => is_array($result['rows'] ?? null) ? $result['rows'] : [], 'rows' => is_array($result['rows'] ?? null) ? $result['rows'] : [],
'columns' => is_array($result['columns'] ?? null) ? $result['columns'] : [], 'columns' => is_array($result['columns'] ?? null) ? $result['columns'] : [],
'count' => (int) ($result['count'] ?? 0), '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) { } 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<array<string,mixed>> $sources */ private function dslForSource(string $sourceName): string
private function dslForSource(array $sources, string $sourceName): string
{ {
foreach ($sources as $source) { $source = $this->service()->getByName($sourceName);
$name = trim((string) ($source['name'] ?? '')); return is_array($source) ? trim((string) ($source['dsl_text'] ?? '')) : '';
if ($name === $sourceName) { }
return trim((string) ($source['dsl_text'] ?? ''));
} /** @return array<string,mixed> */
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 private function service(): DataSourceService

View File

@ -52,6 +52,12 @@ final class DataSourceService
return $this->queries->find($id); return $this->queries->find($id);
} }
/** @return array<string, mixed>|null */
public function getByName(string $name): ?array
{
return $this->queries->findByName($name);
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function validateDsl(string $dsl): array public function validateDsl(string $dsl): array
{ {
@ -131,17 +137,19 @@ final class DataSourceService
public function preview(string $dsl, int $limit = 50): array public function preview(string $dsl, int $limit = 50): array
{ {
$limit = max(1, min(200, $limit)); $limit = max(1, min(200, $limit));
return $this->queryRows($dsl, $limit); return $this->queryRows($dsl, $limit, 0);
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
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<string, mixed> */ /** @return array<string, mixed> */
private function queryRows(string $dsl, ?int $limit): array private function queryRows(string $dsl, int $limit, int $offset): array
{ {
$validation = $this->validateDsl($dsl); $validation = $this->validateDsl($dsl);
if ($validation['errors'] !== []) { if ($validation['errors'] !== []) {
@ -150,19 +158,17 @@ final class DataSourceService
$ast = $validation['ast']; $ast = $validation['ast'];
$compiled = $this->compiler->compile($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 = $this->router->membersPdo()->prepare($countSql);
$stmtCount->execute($compiled['params']); $stmtCount->execute($countCompiled['params']);
$count = (int) $stmtCount->fetchColumn(); $count = (int) $stmtCount->fetchColumn();
$previewSql = $compiled['sql']; $previewSql = $compiled['sql'] . ' LIMIT ' . $limit . ' OFFSET ' . $offset;
if ($limit !== null) {
$previewSql .= ' LIMIT ' . $limit;
}
$stmtRows = $this->router->membersPdo()->prepare($previewSql); $stmtRows = $this->router->membersPdo()->prepare($previewSql);
$stmtRows->execute($compiled['params']); $stmtRows->execute($compiled['params']);
$rows = $stmtRows->fetchAll(PDO::FETCH_ASSOC); $rows = $this->normalizeRowsForJson($stmtRows->fetchAll(PDO::FETCH_ASSOC));
$columnSet = []; $columnSet = [];
foreach ($rows as $row) { foreach ($rows as $row) {
@ -178,9 +184,44 @@ final class DataSourceService
'rows' => $rows, 'rows' => $rows,
'columns' => array_keys($columnSet), 'columns' => array_keys($columnSet),
'expected_fields' => $validation['expected_fields'], 'expected_fields' => $validation['expected_fields'],
'limit' => $limit,
'offset' => $offset,
'returned_count' => count($rows),
'has_more' => ($offset + count($rows)) < $count,
]; ];
} }
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
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<string, list<string>> */ /** @return array<string, list<string>> */
public function sourceFields(): array public function sourceFields(): array
{ {

View File

@ -20,6 +20,24 @@ final class DslCompiler
* @return array{sql:string,params:list<mixed>} * @return array{sql:string,params:list<mixed>}
*/ */
public function compile(array $ast): array public function compile(array $ast): array
{
return $this->compileWithProjection($ast, null);
}
/**
* @param array{sources:list<string>, where:array<int, mixed>} $ast
* @return array{sql:string,params:list<mixed>}
*/
public function compileCountable(array $ast): array
{
return $this->compileWithProjection($ast, '1');
}
/**
* @param array{sources:list<string>, where:array<int, mixed>} $ast
* @return array{sql:string,params:list<mixed>}
*/
private function compileWithProjection(array $ast, ?string $selectSql): array
{ {
$sources = $ast['sources']; $sources = $ast['sources'];
if ($sources === []) { if ($sources === []) {
@ -64,7 +82,7 @@ final class DslCompiler
// Always emit source-qualified projection keys so token names remain stable // Always emit source-qualified projection keys so token names remain stable
// without any post-query alias fallback. // without any post-query alias fallback.
$selectSql = $this->buildUniqueSelectProjection($sources); $selectSql = $selectSql ?? $this->buildUniqueSelectProjection($sources);
$sql = 'SELECT ' . $selectSql . ' FROM ' . $from; $sql = 'SELECT ' . $selectSql . ' FROM ' . $from;
if ($joins !== []) { if ($joins !== []) {
$sql .= ' ' . implode(' ', $joins); $sql .= ' ' . implode(' ', $joins);

View File

@ -54,12 +54,12 @@ final class MailshotRunService
public function previewRecipients(int $mailshotId, int $limit = 100): array public function previewRecipients(int $mailshotId, int $limit = 100): array
{ {
try { try {
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId); $limit = max(1, min(500, $limit));
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId, $limit);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return ['ok' => false, 'errors' => [$e->getMessage()], 'rows' => []]; return ['ok' => false, 'errors' => [$e->getMessage()], 'rows' => []];
} }
$limit = max(1, min(500, $limit));
$rows = array_slice($rows, 0, $limit); $rows = array_slice($rows, 0, $limit);
$out = []; $out = [];
$recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? '')); $recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));
@ -112,7 +112,8 @@ final class MailshotRunService
public function renderTest(int $mailshotId, int $recipientIndex): array public function renderTest(int $mailshotId, int $recipientIndex): array
{ {
try { try {
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId); $rowLimit = max(1, min(5000, $recipientIndex + 1));
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId, $rowLimit);
if (!isset($rows[$recipientIndex])) { if (!isset($rows[$recipientIndex])) {
return ['ok' => false, 'errors' => ['Selected recipient row was not found.']]; return ['ok' => false, 'errors' => ['Selected recipient row was not found.']];
} }
@ -775,7 +776,7 @@ final class MailshotRunService
} }
/** @return array{0:array<string,mixed>,1:list<array<string,mixed>>} */ /** @return array{0:array<string,mixed>,1:list<array<string,mixed>>} */
private function loadMailshotAndRows(int $mailshotId): array private function loadMailshotAndRows(int $mailshotId, int $rowLimit = 5000): array
{ {
$mailshot = $this->mailshots->find($mailshotId); $mailshot = $this->mailshots->find($mailshotId);
if ($mailshot === null) { if ($mailshot === null) {
@ -793,7 +794,7 @@ final class MailshotRunService
throw new \RuntimeException('Data source DSL is empty.'); throw new \RuntimeException('Data source DSL is empty.');
} }
$preview = $this->dataSources->preview($dsl, 5000); $preview = $this->dataSources->preview($dsl, $rowLimit);
if (($preview['errors'] ?? []) !== []) { if (($preview['errors'] ?? []) !== []) {
throw new \RuntimeException('Data source preview failed: ' . implode('; ', $preview['errors'])); throw new \RuntimeException('Data source preview failed: ' . implode('; ', $preview['errors']));
} }

View File

@ -37,11 +37,16 @@ final class MailshotService
return $this->mailshots->all(); return $this->mailshots->all();
} }
/** @return array<string, mixed>|null */
public function find(int $id): ?array
{
return $this->mailshots->find($id);
}
/** @return list<string> */ /** @return list<string> */
public function dataSourceNames(): array public function dataSourceNames(): array
{ {
$rows = $this->queries->all(); return $this->queries->names();
return array_map(static fn(array $r): string => (string) $r['name'], $rows);
} }
/** @return list<string> */ /** @return list<string> */

View File

@ -87,8 +87,9 @@ final class PdoDatabaseRouter implements DatabaseRouter
private function connect(string $dbName): PDO 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( return new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $this->host, $this->port, $dbName), $dsn,
$this->user, $this->user,
$this->pass, $this->pass,
$this->pdoOptions $this->pdoOptions

View File

@ -9,6 +9,8 @@ use PDO;
final class AttachmentRepository final class AttachmentRepository
{ {
private const BLOB_READ_CHUNK_BYTES = 524288;
private DatabaseRouter $router; private DatabaseRouter $router;
public function __construct(DatabaseRouter $router) public function __construct(DatabaseRouter $router)
@ -69,7 +71,7 @@ final class AttachmentRepository
if ($name === '') { if ($name === '') {
return null; 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 FROM mailshot_attachments
WHERE LOWER(name) = LOWER(:name) WHERE LOWER(name) = LOWER(:name)
LIMIT 1'; LIMIT 1';
@ -79,7 +81,14 @@ final class AttachmentRepository
if ($row === false) { if ($row === false) {
return null; 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'] ?? '')); $mimeType = trim((string) ($row['mime_type'] ?? ''));
if ($mimeType === '') { if ($mimeType === '') {
$mimeType = $this->inferMimeTypeFromFilename((string) ($row['file_name'] ?? '')); $mimeType = $this->inferMimeTypeFromFilename((string) ($row['file_name'] ?? ''));
@ -91,6 +100,34 @@ final class AttachmentRepository
return $row; 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 private function updateMimeType(int $id, string $mimeType): void
{ {
if ($id <= 0 || trim($mimeType) === '') { if ($id <= 0 || trim($mimeType) === '') {

View File

@ -19,10 +19,17 @@ final class MailshotQueryRepository
/** @return list<array<string, mixed>> */ /** @return list<array<string, mixed>> */
public function all(): array 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 $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
} }
/** @return list<string> */
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 public function find(int $id): ?array
{ {
$stmt = $this->router->mailshotsPdo()->prepare('SELECT ID, name, dsl_text, updated_at FROM mailshot_queries WHERE ID = :id'); $stmt = $this->router->mailshotsPdo()->prepare('SELECT ID, name, dsl_text, updated_at FROM mailshot_queries WHERE ID = :id');

View File

@ -21,7 +21,7 @@ final class MailshotRepository
public function all(): array public function all(): array
{ {
$this->ensureRecipientEmailFieldColumn(); $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); return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
} }

View File

@ -9,6 +9,8 @@ use PDO;
final class PdfAssetRepository final class PdfAssetRepository
{ {
private const BLOB_READ_CHUNK_BYTES = 524288;
private DatabaseRouter $router; private DatabaseRouter $router;
public function __construct(DatabaseRouter $router) public function __construct(DatabaseRouter $router)
@ -30,7 +32,7 @@ final class PdfAssetRepository
if ($name === '') { if ($name === '') {
return null; 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 FROM mailshot_pdf_assets
WHERE LOWER(name) = LOWER(:name) WHERE LOWER(name) = LOWER(:name)
LIMIT 1'; LIMIT 1';
@ -40,10 +42,45 @@ final class PdfAssetRepository
if ($row === false) { if ($row === false) {
return null; 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; 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<string, mixed> $row */ /** @param array<string, mixed> $row */
public function create(array $row): int public function create(array $row): int
{ {

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Repository\MailshotQueryRepository;
Env::load(dirname(__DIR__, 2) . '/credentials/.env');
$dbConfig = [
'MYSQL_HOST' => '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";

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Repository\MailshotRepository;
Env::load(dirname(__DIR__, 2) . '/credentials/.env');
$dbConfig = [
'MYSQL_HOST' => '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";

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Application\DataSourceService;
use FecaMailshots\Application\DslCompiler;
use FecaMailshots\Application\DslValidator;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Infrastructure\DatabaseSourceMetadataProvider;
use FecaMailshots\Infrastructure\Env;
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
use FecaMailshots\Repository\MailshotQueryRepository;
use FecaMailshots\Repository\MailshotRepository;
Env::load(dirname(__DIR__, 2) . '/credentials/.env');
$dbConfig = [
'MYSQL_HOST' => '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";

View File

@ -124,6 +124,13 @@ foreach ($cases as $case) {
if (strpos($compiled['sql'], 'SELECT ') !== 0) { if (strpos($compiled['sql'], 'SELECT ') !== 0) {
$failures[] = ['dsl' => $case['dsl'], 'errors' => ['Compilation did not produce SELECT']]; $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']];
}
} }
} }