Chasing memory allocation bugs
This commit is contained in:
parent
69b52c2dd9
commit
8fc530c51b
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'] ?? '') : '';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,""")+"\\""+(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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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']));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> */
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) === '') {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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']];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue