implementation of fen database

This commit is contained in:
Adrian Stephens 2026-05-09 07:49:16 +01:00
parent e02696ea2f
commit 4a667a8c35
25 changed files with 548 additions and 44 deletions

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.0.3 * Version: 1.0.4
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Author: FECA * Author: FECA

View File

@ -186,7 +186,13 @@ final class DataSourcesAdminPage
echo '<label><input type="checkbox" class="ds-source-built" value="contacts"> contacts</label><br>'; echo '<label><input type="checkbox" class="ds-source-built" value="contacts"> contacts</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="accounts"> accounts</label><br>'; echo '<label><input type="checkbox" class="ds-source-built" value="accounts"> accounts</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="renewals"> renewals</label><br>'; echo '<label><input type="checkbox" class="ds-source-built" value="renewals"> renewals</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="grants"> grants</label><br><br>'; echo '<label><input type="checkbox" class="ds-source-built" value="grants"> grants</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="advertisers"> advertisers</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="ads"> ads</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="pages"> pages</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="articles"> articles</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="issues"> issues</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="invoices"> invoices</label><br><br>';
echo '<strong>Add custom source</strong><br>'; echo '<strong>Add custom source</strong><br>';
echo '<label>Schema <select id="ds-builder-schema"><option value="">Select schema</option>'; echo '<label>Schema <select id="ds-builder-schema"><option value="">Select schema</option>';
foreach ($schemaList as $schema) { foreach ($schemaList as $schema) {
@ -310,29 +316,33 @@ final class DataSourcesAdminPage
echo 'var tableSel=document.getElementById("ds-builder-table");'; echo 'var tableSel=document.getElementById("ds-builder-table");';
echo 'var addCustomBtn=document.getElementById("ds-builder-add-custom");'; echo 'var addCustomBtn=document.getElementById("ds-builder-add-custom");';
echo 'var customSources=[];'; echo 'var customSources=[];';
echo 'var sourceOrder=[];';
echo 'var constraints=[];'; echo 'var constraints=[];';
echo 'var isDirty=false;'; echo 'var isDirty=false;';
echo 'function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}'; echo 'function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}';
echo 'if(editorForm){editorForm.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});editorForm.addEventListener("submit",function(){isDirty=false;});}'; echo 'if(editorForm){editorForm.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});editorForm.addEventListener("submit",function(){isDirty=false;});}';
echo 'var filters=[["selected-renewal","Renewal is selected"],["pending-renewal","Renewal is pending"],["primary-contact","Contact is primary"],["fen1-contact","Contact is FEN1"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council"]];'; echo 'var filters=[["selected-renewal","Renewal is selected","none"],["pending-renewal","Renewal is pending","none"],["primary-contact","Contact is primary","none"],["fen1-contact","Contact is FEN1","none"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council","none"],["selected","Advertiser is selected","none"],["page-in-issue","Page is in issue","issue"],["ad-in-issue","Advertiser has ad in issue","issue"],["pending-invoice","Invoice is pending","none"],["selected-invoice","Invoice is selected","none"],["invoice-ids","Invoice ID is one of","ids"]];';
echo 'function selectedSources(){var s=[];builtChecks.forEach(function(c){if(c.checked){s.push(c.value);}});customSources.forEach(function(v){s.push(v);});return s;}'; echo 'function selectedSources(){var selected={};builtChecks.forEach(function(c){if(c.checked){selected[c.value]=true;}});customSources.forEach(function(v){selected[v]=true;});var s=[];sourceOrder.forEach(function(src){if(selected[src]&&s.indexOf(src)===-1){s.push(src);}});builtChecks.forEach(function(c){if(c.checked&&s.indexOf(c.value)===-1){s.push(c.value);}});customSources.forEach(function(v){if(s.indexOf(v)===-1){s.push(v);}});return s;}';
echo 'function updateCustomList(){customList.innerHTML="";customSources.forEach(function(src,i){var li=document.createElement("li");li.textContent=src+" ";var b=document.createElement("button");b.type="button";b.className="button-link-delete";b.textContent="Remove";b.onclick=function(){customSources.splice(i,1);updateCustomList();renderRows();updateDsl();};li.appendChild(b);customList.appendChild(li);});}'; echo 'function noteSourceSelected(src){if(src&&sourceOrder.indexOf(src)===-1){sourceOrder.push(src);}}';
echo 'function noteSourceDeselected(src){sourceOrder=sourceOrder.filter(function(v){return v!==src;});}';
echo 'function updateCustomList(){customList.innerHTML="";customSources.forEach(function(src,i){var li=document.createElement("li");li.textContent=src+" ";var b=document.createElement("button");b.type="button";b.className="button-link-delete";b.textContent="Remove";b.onclick=function(){noteSourceDeselected(src);customSources.splice(i,1);updateCustomList();renderRows();updateDsl();};li.appendChild(b);customList.appendChild(li);});}';
echo 'function fetchTables(schema){tableSel.innerHTML="<option value=\"\">Loading...</option>";tableSel.style.color="#1d2327";if(tableErr){tableErr.textContent="";}var pickLabel=function(t){if(typeof t==="string"){return t.trim();}if(t===null||t===undefined){return "";}if(typeof t==="number"){return String(t);}if(typeof t==="object"){var direct=[t.table_name,t.name,t.table,t.label];for(var i=0;i<direct.length;i++){var dv=String(direct[i]||"").trim();if(dv){return dv;}}var keys=Object.keys(t||{});for(var k=0;k<keys.length;k++){var key=String(keys[k]||"").trim();if(/^Tables_in_/i.test(key)){var vv=String(t[key]||"").trim();if(vv){return vv;}}}var vals=Object.values(t||{});for(var j=0;j<vals.length;j++){var v=String(vals[j]||"").trim();if(v){return v;}}}return "";};var fill=function(items){tableSel.innerHTML="<option value=\"\">Select table</option>";var invalid=0;(items||[]).forEach(function(t){var label=pickLabel(t);if(!label){invalid++;return;}var o=document.createElement("option");o.value=label;o.textContent=label;tableSel.appendChild(o);});if((items||[]).length===0&&tableErr){tableErr.textContent="No tables returned for this schema. Check DB grants: SELECT and SHOW VIEW on schema tables.";}if(invalid>0&&tableErr){tableErr.textContent="Received "+invalid+" table entries without names; showing only valid table names.";}};var showErr=function(msg){tableSel.innerHTML="<option value=\"\">Select table</option>";if(tableErr){tableErr.textContent=msg||"Table lookup failed. Resolve the error before continuing.";}if(err){err.textContent="Table lookup failed: "+(msg||"unknown error");}};var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=tables&schema="+encodeURIComponent(schema);fetch(u,{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){throw new Error((j&&j.error)||"API error");}fill((j&&j.items)||[]);}).catch(function(e){showErr(e&&e.message?e.message:"Need MySQL grants on the selected schema.");});}'; echo 'function fetchTables(schema){tableSel.innerHTML="<option value=\"\">Loading...</option>";tableSel.style.color="#1d2327";if(tableErr){tableErr.textContent="";}var pickLabel=function(t){if(typeof t==="string"){return t.trim();}if(t===null||t===undefined){return "";}if(typeof t==="number"){return String(t);}if(typeof t==="object"){var direct=[t.table_name,t.name,t.table,t.label];for(var i=0;i<direct.length;i++){var dv=String(direct[i]||"").trim();if(dv){return dv;}}var keys=Object.keys(t||{});for(var k=0;k<keys.length;k++){var key=String(keys[k]||"").trim();if(/^Tables_in_/i.test(key)){var vv=String(t[key]||"").trim();if(vv){return vv;}}}var vals=Object.values(t||{});for(var j=0;j<vals.length;j++){var v=String(vals[j]||"").trim();if(v){return v;}}}return "";};var fill=function(items){tableSel.innerHTML="<option value=\"\">Select table</option>";var invalid=0;(items||[]).forEach(function(t){var label=pickLabel(t);if(!label){invalid++;return;}var o=document.createElement("option");o.value=label;o.textContent=label;tableSel.appendChild(o);});if((items||[]).length===0&&tableErr){tableErr.textContent="No tables returned for this schema. Check DB grants: SELECT and SHOW VIEW on schema tables.";}if(invalid>0&&tableErr){tableErr.textContent="Received "+invalid+" table entries without names; showing only valid table names.";}};var showErr=function(msg){tableSel.innerHTML="<option value=\"\">Select table</option>";if(tableErr){tableErr.textContent=msg||"Table lookup failed. Resolve the error before continuing.";}if(err){err.textContent="Table lookup failed: "+(msg||"unknown error");}};var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=tables&schema="+encodeURIComponent(schema);fetch(u,{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){throw new Error((j&&j.error)||"API error");}fill((j&&j.items)||[]);}).catch(function(e){showErr(e&&e.message?e.message:"Need MySQL grants on the selected schema.");});}';
echo 'function sourceFields(source){var map=cfg.sourceFields||{};if(map[source]){return map[source];}if(source&&source.indexOf(".")!==-1){var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=source_fields&source="+encodeURIComponent(source);fetch(u,{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){throw new Error((j&&j.error)||"API error");}var fields=(j&&j.fields)||[];if(fields&&fields.length){map[source]=fields;cfg.sourceFields=map;renderRows();updateDsl();return;}if(err){err.textContent="Source field lookup returned no fields for "+source+".";}}).catch(function(e){if(err){err.textContent="Source field lookup failed for "+source+": "+(e&&e.message?e.message:"unknown error");}});}return [];}'; echo 'function sourceFields(source){var map=cfg.sourceFields||{};if(map[source]){return map[source];}if(source&&source.indexOf(".")!==-1){var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=source_fields&source="+encodeURIComponent(source);fetch(u,{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){throw new Error((j&&j.error)||"API error");}var fields=(j&&j.fields)||[];if(fields&&fields.length){map[source]=fields;cfg.sourceFields=map;renderRows();updateDsl();return;}if(err){err.textContent="Source field lookup returned no fields for "+source+".";}}).catch(function(e){if(err){err.textContent="Source field lookup failed for "+source+": "+(e&&e.message?e.message:"unknown error");}});}return [];}';
echo 'function mkSelect(options,value){var s=document.createElement("select");options.forEach(function(opt){var o=document.createElement("option");o.value=opt[0];o.textContent=opt[1];if(opt[0]===value){o.selected=true;}s.appendChild(o);});return s;}'; echo 'function mkSelect(options,value){var s=document.createElement("select");options.forEach(function(opt){var o=document.createElement("option");o.value=opt[0];o.textContent=opt[1];if(opt[0]===value){o.selected=true;}s.appendChild(o);});return s;}';
echo 'function addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});renderRows();updateDsl();}'; echo 'function filterArgType(name){for(var i=0;i<filters.length;i++){if(filters[i][0]===name){return filters[i][2]||"none";}}return "none";}';
echo 'function renderRows(){rowsWrap.innerHTML="";constraints.forEach(function(row,idx){var box=document.createElement("div");box.className="feca-builder-row";var top=document.createElement("div");var not=document.createElement("input");not.type="checkbox";not.checked=!!row.negate;not.onchange=function(){row.negate=not.checked;updateDsl();};top.appendChild(not);top.appendChild(document.createTextNode(" NOT "));var kind=mkSelect([["filter","Predefined Filter"],["compare","Field Comparison"]],row.kind);kind.onchange=function(){row.kind=kind.value;renderRows();updateDsl();};top.appendChild(kind);var rem=document.createElement("button");rem.type="button";rem.className="button-link-delete";rem.textContent="Remove";rem.onclick=function(){constraints.splice(idx,1);renderRows();updateDsl();};top.appendChild(rem);box.appendChild(top);if(row.kind==="filter"){var f=mkSelect(filters,row.filter);f.className="feca-builder-filter-select";f.onchange=function(){row.filter=f.value;updateDsl();};box.appendChild(f);}else{var srcs=selectedSources().map(function(s){return [s,s];});if(srcs.length===0){srcs=[["","Select source"]];}else{srcs.unshift(["","Select source"]);}var lhsS=mkSelect(srcs,row.lhsSource);lhsS.onchange=function(){row.lhsSource=lhsS.value;row.lhsField="";renderRows();updateDsl();};box.appendChild(lhsS);var lhsFields=(row.lhsSource?sourceFields(row.lhsSource):[]).map(function(f){return [f,f]});lhsFields.unshift(["","Field"]);var lhsF=mkSelect(lhsFields,row.lhsField);lhsF.onchange=function(){row.lhsField=lhsF.value;updateDsl();};box.appendChild(lhsF);var op=mkSelect([["=","="],["!=","!="],["contains","contains"],["starts-with","starts-with"],["ends-with","ends-with"],["in","in"]],row.op);op.onchange=function(){row.op=op.value;updateDsl();};box.appendChild(op);var mode=mkSelect([["literal","Literal"],["field","Field ref"]],row.rhsMode);mode.onchange=function(){row.rhsMode=mode.value;renderRows();updateDsl();};box.appendChild(mode);if(row.rhsMode==="literal"){var input=document.createElement("input");input.type="text";input.value=row.rhsLiteral||"";input.placeholder="Value";input.oninput=function(){row.rhsLiteral=input.value;updateDsl();};box.appendChild(input);}else{var rhsS=mkSelect(srcs,row.rhsSource);rhsS.onchange=function(){row.rhsSource=rhsS.value;row.rhsField="";renderRows();updateDsl();};box.appendChild(rhsS);var rhsFields=(row.rhsSource?sourceFields(row.rhsSource):[]).map(function(f){return [f,f]});rhsFields.unshift(["","Field"]);var rhsF=mkSelect(rhsFields,row.rhsField);rhsF.onchange=function(){row.rhsField=rhsF.value;updateDsl();};box.appendChild(rhsF);}}rowsWrap.appendChild(box);});}'; echo 'function addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",filterArg:"",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});renderRows();updateDsl();}';
echo 'function renderRows(){rowsWrap.innerHTML="";constraints.forEach(function(row,idx){var box=document.createElement("div");box.className="feca-builder-row";var top=document.createElement("div");var not=document.createElement("input");not.type="checkbox";not.checked=!!row.negate;not.onchange=function(){row.negate=not.checked;updateDsl();};top.appendChild(not);top.appendChild(document.createTextNode(" NOT "));var kind=mkSelect([["filter","Predefined Filter"],["compare","Field Comparison"]],row.kind);kind.onchange=function(){row.kind=kind.value;renderRows();updateDsl();};top.appendChild(kind);var rem=document.createElement("button");rem.type="button";rem.className="button-link-delete";rem.textContent="Remove";rem.onclick=function(){constraints.splice(idx,1);renderRows();updateDsl();};top.appendChild(rem);box.appendChild(top);if(row.kind==="filter"){var f=mkSelect(filters,row.filter);f.className="feca-builder-filter-select";f.onchange=function(){row.filter=f.value;renderRows();updateDsl();};box.appendChild(f);var argType=filterArgType(row.filter);if(argType!=="none"){var arg=document.createElement("input");arg.type="text";arg.value=row.filterArg||"";arg.placeholder=argType==="ids"?"IDs, comma separated":"Issue ID";arg.oninput=function(){row.filterArg=arg.value;updateDsl();};box.appendChild(arg);}}else{var srcs=selectedSources().map(function(s){return [s,s];});if(srcs.length===0){srcs=[["","Select source"]];}else{srcs.unshift(["","Select source"]);}var lhsS=mkSelect(srcs,row.lhsSource);lhsS.onchange=function(){row.lhsSource=lhsS.value;row.lhsField="";renderRows();updateDsl();};box.appendChild(lhsS);var lhsFields=(row.lhsSource?sourceFields(row.lhsSource):[]).map(function(f){return [f,f]});lhsFields.unshift(["","Field"]);var lhsF=mkSelect(lhsFields,row.lhsField);lhsF.onchange=function(){row.lhsField=lhsF.value;updateDsl();};box.appendChild(lhsF);var op=mkSelect([["=","="],["!=","!="],["contains","contains"],["starts-with","starts-with"],["ends-with","ends-with"],["in","in"]],row.op);op.onchange=function(){row.op=op.value;updateDsl();};box.appendChild(op);var mode=mkSelect([["literal","Literal"],["field","Field ref"]],row.rhsMode);mode.onchange=function(){row.rhsMode=mode.value;renderRows();updateDsl();};box.appendChild(mode);if(row.rhsMode==="literal"){var input=document.createElement("input");input.type="text";input.value=row.rhsLiteral||"";input.placeholder="Value";input.oninput=function(){row.rhsLiteral=input.value;updateDsl();};box.appendChild(input);}else{var rhsS=mkSelect(srcs,row.rhsSource);rhsS.onchange=function(){row.rhsSource=rhsS.value;row.rhsField="";renderRows();updateDsl();};box.appendChild(rhsS);var rhsFields=(row.rhsSource?sourceFields(row.rhsSource):[]).map(function(f){return [f,f]});rhsFields.unshift(["","Field"]);var rhsF=mkSelect(rhsFields,row.rhsField);rhsF.onchange=function(){row.rhsField=rhsF.value;updateDsl();};box.appendChild(rhsF);}}rowsWrap.appendChild(box);});}';
echo 'function quote(v){if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return "\'"+String(v).replace(/\'/g,"")+"\'";}'; echo 'function quote(v){if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return "\'"+String(v).replace(/\'/g,"")+"\'";}';
echo 'function updateDsl(){err.textContent="";var srcs=selectedSources();if(srcs.length===0){out.value="";err.textContent="Select at least one data source.";return;}var base=srcs.join(" and ");var terms=[];for(var i=0;i<constraints.length;i++){var r=constraints[i];var t="";if(r.kind==="filter"){if(!r.filter){continue;}t=r.filter;}else{if(!r.lhsSource||!r.lhsField){continue;}var lhs=r.lhsSource+"."+r.lhsField;if(r.rhsMode==="field"){if(!r.rhsSource||!r.rhsField){continue;}var rhs=r.rhsSource+"."+r.rhsField;if(r.op==="in"){t=lhs+" in ("+rhs+")";}else{t=lhs+" "+r.op+" "+rhs;}}else{if((r.rhsLiteral||"")===""){continue;}if(r.op==="in"){t=lhs+" in ("+quote(r.rhsLiteral)+")";}else{t=lhs+" "+r.op+" "+quote(r.rhsLiteral);}}}if(r.negate&&t!==""){t="not ("+t+")";}if(t!==""){terms.push(t);}}out.value=base+(terms.length>0?" where "+terms.join(" and "):"");}'; echo 'function updateDsl(){err.textContent="";var srcs=selectedSources();if(srcs.length===0){out.value="";err.textContent="Select at least one data source.";return;}var base=srcs.join(" and ");var terms=[];for(var i=0;i<constraints.length;i++){var r=constraints[i];var t="";if(r.kind==="filter"){if(!r.filter){continue;}var argType=filterArgType(r.filter);if(argType==="none"){t=r.filter;}else{var raw=String(r.filterArg||"").trim();if(!raw){continue;}if(argType==="ids"){var ids=raw.split(",").map(function(v){return v.trim();}).filter(function(v){return /^\\d+$/.test(v);});if(ids.length===0){continue;}t=r.filter+"("+ids.join(", ")+")";}else{if(!/^\\d+$/.test(raw)){continue;}t=r.filter+"("+raw+")";}}}else{if(!r.lhsSource||!r.lhsField){continue;}var lhs=r.lhsSource+"."+r.lhsField;if(r.rhsMode==="field"){if(!r.rhsSource||!r.rhsField){continue;}var rhs=r.rhsSource+"."+r.rhsField;if(r.op==="in"){t=lhs+" in ("+rhs+")";}else{t=lhs+" "+r.op+" "+rhs;}}else{if((r.rhsLiteral||"")===""){continue;}if(r.op==="in"){t=lhs+" in ("+quote(r.rhsLiteral)+")";}else{t=lhs+" "+r.op+" "+quote(r.rhsLiteral);}}}if(r.negate&&t!==""){t="not ("+t+")";}if(t!==""){terms.push(t);}}out.value=base+(terms.length>0?" where "+terms.join(" and "):"");}';
echo 'function resetBuilder(){builtChecks.forEach(function(c){c.checked=false;});customSources=[];constraints=[];updateCustomList();renderRows();updateDsl();}'; echo 'function resetBuilder(){builtChecks.forEach(function(c){c.checked=false;});customSources=[];sourceOrder=[];constraints=[];updateCustomList();renderRows();updateDsl();}';
echo 'function splitAndTerms(text){var out=[];var cur="";var depth=0;var inQuote=false;for(var i=0;i<text.length;i++){var ch=text.charAt(i);if(ch==="\'"){inQuote=!inQuote;cur+=ch;continue;}if(!inQuote){if(ch==="("){depth++;cur+=ch;continue;}if(ch===")"){if(depth>0){depth--;}cur+=ch;continue;}if(depth===0&&text.substring(i,i+5)===" and "){if(cur.trim()){out.push(cur.trim());}cur="";i+=4;continue;}}cur+=ch;}if(cur.trim()){out.push(cur.trim());}return out;}'; echo 'function splitAndTerms(text){var out=[];var cur="";var depth=0;var inQuote=false;for(var i=0;i<text.length;i++){var ch=text.charAt(i);if(ch==="\'"){inQuote=!inQuote;cur+=ch;continue;}if(!inQuote){if(ch==="("){depth++;cur+=ch;continue;}if(ch===")"){if(depth>0){depth--;}cur+=ch;continue;}if(depth===0&&text.substring(i,i+5)===" and "){if(cur.trim()){out.push(cur.trim());}cur="";i+=4;continue;}}cur+=ch;}if(cur.trim()){out.push(cur.trim());}return out;}';
echo 'function splitFieldRef(text){var s=String(text||"").trim();var idx=s.lastIndexOf(".");if(idx<=0||idx>=s.length-1){return null;}return {source:s.substring(0,idx),field:s.substring(idx+1)};}'; echo 'function splitFieldRef(text){var s=String(text||"").trim();var idx=s.lastIndexOf(".");if(idx<=0||idx>=s.length-1){return null;}return {source:s.substring(0,idx),field:s.substring(idx+1)};}';
echo 'function parseLiteralValue(text){var v=String(text||"").trim();if(!v){return null;}if(v.length>=2&&v.charAt(0)==="\'"&&v.charAt(v.length-1)==="\'"){return v.substring(1,v.length-1);}if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return null;}'; echo 'function parseLiteralValue(text){var v=String(text||"").trim();if(!v){return null;}if(v.length>=2&&v.charAt(0)==="\'"&&v.charAt(v.length-1)==="\'"){return v.substring(1,v.length-1);}if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return null;}';
echo 'function parseCompareTerm(term){var m=/^(.+?)\\s+(=|!=|contains|starts-with|ends-with|in)\\s+(.+)$/.exec(String(term||"").trim());if(!m){return null;}var lhsRef=splitFieldRef(m[1]);if(!lhsRef){return null;}var op=String(m[2]||"");var rhsRaw=String(m[3]||"").trim();if(!rhsRaw){return null;}var row={kind:"compare",negate:false,filter:"selected-renewal",lhsSource:lhsRef.source,lhsField:lhsRef.field,op:op,rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""};if(op==="in"){if(rhsRaw.charAt(0)!=="("||rhsRaw.charAt(rhsRaw.length-1)!==")"){return null;}rhsRaw=rhsRaw.substring(1,rhsRaw.length-1).trim();if(!rhsRaw){return null;}}var rhsRef=splitFieldRef(rhsRaw);if(rhsRef){row.rhsMode="field";row.rhsSource=rhsRef.source;row.rhsField=rhsRef.field;return row;}var literal=parseLiteralValue(rhsRaw);if(literal===null){return null;}row.rhsLiteral=literal;return row;}'; echo 'function parseCompareTerm(term){var m=/^(.+?)\\s+(=|!=|contains|starts-with|ends-with|in)\\s+(.+)$/.exec(String(term||"").trim());if(!m){return null;}var lhsRef=splitFieldRef(m[1]);if(!lhsRef){return null;}var op=String(m[2]||"");var rhsRaw=String(m[3]||"").trim();if(!rhsRaw){return null;}var row={kind:"compare",negate:false,filter:"selected-renewal",lhsSource:lhsRef.source,lhsField:lhsRef.field,op:op,rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""};if(op==="in"){if(rhsRaw.charAt(0)!=="("||rhsRaw.charAt(rhsRaw.length-1)!==")"){return null;}rhsRaw=rhsRaw.substring(1,rhsRaw.length-1).trim();if(!rhsRaw){return null;}}var rhsRef=splitFieldRef(rhsRaw);if(rhsRef){row.rhsMode="field";row.rhsSource=rhsRef.source;row.rhsField=rhsRef.field;return row;}var literal=parseLiteralValue(rhsRaw);if(literal===null){return null;}row.rhsLiteral=literal;return row;}';
echo 'function prefillFromDsl(){var text=(dslInput&&dslInput.value?dslInput.value:"").trim();if(!text){return;}var lower=text.toLowerCase();var whereIndex=lower.indexOf(" where ");var srcPart=whereIndex>=0?text.substring(0,whereIndex):text;var wherePart=whereIndex>=0?text.substring(whereIndex+7).trim():"";var bits=srcPart.split(" and ");bits.map(function(v){return v.trim();}).forEach(function(src){if(!src){return;}var built=false;builtChecks.forEach(function(c){if(c.value===src){c.checked=true;built=true;}});if(!built&&customSources.indexOf(src)===-1){customSources.push(src);}});constraints=[];if(wherePart){var known={};filters.forEach(function(f){known[f[0]]=true;});splitAndTerms(wherePart).forEach(function(term){var t=(term||"").trim();if(!t){return;}var negate=false;var lt=t.toLowerCase();if(lt.indexOf("not (")===0&&t.charAt(t.length-1)===")"){negate=true;t=t.substring(5,t.length-1).trim();}else if(lt.indexOf("not ")===0){negate=true;t=t.substring(4).trim();}if(known[t]){constraints.push({kind:"filter",negate:negate,filter:t,lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});return;}var parsed=parseCompareTerm(t);if(parsed){parsed.negate=negate;constraints.push(parsed);}});}updateCustomList();renderRows();updateDsl();}'; echo 'function prefillFromDsl(){var text=(dslInput&&dslInput.value?dslInput.value:"").trim();if(!text){return;}var lower=text.toLowerCase();var whereIndex=lower.indexOf(" where ");var srcPart=whereIndex>=0?text.substring(0,whereIndex):text;var wherePart=whereIndex>=0?text.substring(whereIndex+7).trim():"";var bits=srcPart.split(" and ");sourceOrder=[];bits.map(function(v){return v.trim();}).forEach(function(src){if(!src){return;}noteSourceSelected(src);var built=false;builtChecks.forEach(function(c){if(c.value===src){c.checked=true;built=true;}});if(!built&&customSources.indexOf(src)===-1){customSources.push(src);}});constraints=[];if(wherePart){var known={};filters.forEach(function(f){known[f[0]]=true;});splitAndTerms(wherePart).forEach(function(term){var t=(term||"").trim();if(!t){return;}var negate=false;var lt=t.toLowerCase();if(lt.indexOf("not (")===0&&t.charAt(t.length-1)===")"){negate=true;t=t.substring(5,t.length-1).trim();}else if(lt.indexOf("not ")===0){negate=true;t=t.substring(4).trim();}if(known[t]){constraints.push({kind:"filter",negate:negate,filter:t,filterArg:"",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});return;}var fm=/^([a-z0-9_-]+)\\(([^)]*)\\)$/i.exec(t);if(fm&&known[fm[1]]){constraints.push({kind:"filter",negate:negate,filter:fm[1],filterArg:fm[2],lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});return;}var parsed=parseCompareTerm(t);if(parsed){parsed.negate=negate;constraints.push(parsed);}});}updateCustomList();renderRows();updateDsl();}';
echo 'if(schemaSel){schemaSel.onchange=function(){if(schemaSel.value){fetchTables(schemaSel.value);}else{tableSel.innerHTML="<option value=\"\">Select table</option>";}};if(schemaSel.value){fetchTables(schemaSel.value);}}'; echo 'if(schemaSel){schemaSel.onchange=function(){if(schemaSel.value){fetchTables(schemaSel.value);}else{tableSel.innerHTML="<option value=\"\">Select table</option>";}};if(schemaSel.value){fetchTables(schemaSel.value);}}';
echo 'if(addCustomBtn){addCustomBtn.onclick=function(){if(!schemaSel.value||!tableSel.value){return;}var src=schemaSel.value+"."+tableSel.value;if(customSources.indexOf(src)===-1){customSources.push(src);}updateCustomList();renderRows();updateDsl();};}'; echo 'if(addCustomBtn){addCustomBtn.onclick=function(){if(!schemaSel.value||!tableSel.value){return;}var src=schemaSel.value+"."+tableSel.value;if(customSources.indexOf(src)===-1){customSources.push(src);}noteSourceSelected(src);updateCustomList();renderRows();updateDsl();};}';
echo 'builtChecks.forEach(function(c){c.onchange=updateDsl;});'; echo 'builtChecks.forEach(function(c){c.onchange=function(){if(c.checked){noteSourceSelected(c.value);}else{noteSourceDeselected(c.value);}renderRows();updateDsl();};});';
echo 'if(addRowBtn){addRowBtn.onclick=addConstraint;}'; echo 'if(addRowBtn){addRowBtn.onclick=addConstraint;}';
echo 'if(openBtn){openBtn.onclick=function(){resetBuilder();prefillFromDsl();modal.style.display="block";};}'; echo 'if(openBtn){openBtn.onclick=function(){resetBuilder();prefillFromDsl();modal.style.display="block";};}';
echo 'if(cancelBtn){cancelBtn.onclick=function(){modal.style.display="none";};}'; echo 'if(cancelBtn){cancelBtn.onclick=function(){modal.style.display="none";};}';

View File

@ -78,6 +78,7 @@ final class SetupAdminPage
$this->field('Password', 'db_password', $saved['db_password'] ?? '', 'password'); $this->field('Password', 'db_password', $saved['db_password'] ?? '', 'password');
$this->field('Mailshots DB Name', 'mailshots_db_name', $saved['mailshots_db_name'] ?? ''); $this->field('Mailshots DB Name', 'mailshots_db_name', $saved['mailshots_db_name'] ?? '');
$this->field('Members DB Name', 'members_db_name', $saved['members_db_name'] ?? ''); $this->field('Members DB Name', 'members_db_name', $saved['members_db_name'] ?? '');
$this->field('FEN DB Name', 'fen_db_name', $saved['fen_db_name'] ?? '');
$this->field( $this->field(
'Download PDF Memory Limit', 'Download PDF Memory Limit',
'download_memory_limit', 'download_memory_limit',
@ -108,6 +109,7 @@ final class SetupAdminPage
'db_password' => (string) ($this->wp->requestParam('db_password', '') ?? ''), 'db_password' => (string) ($this->wp->requestParam('db_password', '') ?? ''),
'mailshots_db_name' => trim((string) ($this->wp->requestParam('mailshots_db_name', '') ?? '')), 'mailshots_db_name' => trim((string) ($this->wp->requestParam('mailshots_db_name', '') ?? '')),
'members_db_name' => trim((string) ($this->wp->requestParam('members_db_name', '') ?? '')), 'members_db_name' => trim((string) ($this->wp->requestParam('members_db_name', '') ?? '')),
'fen_db_name' => trim((string) ($this->wp->requestParam('fen_db_name', '') ?? '')),
]; ];
$downloadMemoryLimit = trim((string) ($this->wp->requestParam('download_memory_limit', '') ?? '')); $downloadMemoryLimit = trim((string) ($this->wp->requestParam('download_memory_limit', '') ?? ''));
if ($downloadMemoryLimit !== '' && $this->memoryLimitToBytes($downloadMemoryLimit) <= 0) { if ($downloadMemoryLimit !== '' && $this->memoryLimitToBytes($downloadMemoryLimit) <= 0) {
@ -143,6 +145,7 @@ final class SetupAdminPage
'db_password' => (string) ($this->wp->requestParam('db_password', '') ?? ''), 'db_password' => (string) ($this->wp->requestParam('db_password', '') ?? ''),
'mailshots_db_name' => trim((string) ($this->wp->requestParam('mailshots_db_name', '') ?? '')), 'mailshots_db_name' => trim((string) ($this->wp->requestParam('mailshots_db_name', '') ?? '')),
'members_db_name' => trim((string) ($this->wp->requestParam('members_db_name', '') ?? '')), 'members_db_name' => trim((string) ($this->wp->requestParam('members_db_name', '') ?? '')),
'fen_db_name' => trim((string) ($this->wp->requestParam('fen_db_name', '') ?? '')),
]; ];
$result = $this->runConnectionTest($settings); $result = $this->runConnectionTest($settings);
@ -165,7 +168,7 @@ final class SetupAdminPage
return []; return [];
} }
$out = []; $out = [];
foreach (['db_host', 'db_port', 'db_user', 'db_password', 'mailshots_db_name', 'members_db_name'] as $key) { foreach (['db_host', 'db_port', 'db_user', 'db_password', 'mailshots_db_name', 'members_db_name', 'fen_db_name'] as $key) {
$out[$key] = isset($raw[$key]) ? (string) $raw[$key] : ''; $out[$key] = isset($raw[$key]) ? (string) $raw[$key] : '';
} }
return $out; return $out;
@ -215,7 +218,7 @@ final class SetupAdminPage
private function runConnectionTest(array $settings): array private function runConnectionTest(array $settings): array
{ {
$messages = []; $messages = [];
foreach (['db_host', 'db_port', 'db_user', 'mailshots_db_name', 'members_db_name'] as $required) { foreach (['db_host', 'db_port', 'db_user', 'mailshots_db_name', 'members_db_name', 'fen_db_name'] as $required) {
if (($settings[$required] ?? '') === '') { if (($settings[$required] ?? '') === '') {
$messages[] = sprintf('Missing required field: %s', $required); $messages[] = sprintf('Missing required field: %s', $required);
} }
@ -249,7 +252,7 @@ final class SetupAdminPage
return ['ok' => false, 'messages' => ['MySQL server connection failed: ' . $e->getMessage()]]; return ['ok' => false, 'messages' => ['MySQL server connection failed: ' . $e->getMessage()]];
} }
foreach (['mailshots_db_name' => 'Mailshots DB', 'members_db_name' => 'Members DB'] as $key => $label) { foreach (['mailshots_db_name' => 'Mailshots DB', 'members_db_name' => 'Members DB', 'fen_db_name' => 'FEN DB'] as $key => $label) {
try { try {
$dsn = sprintf( $dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', 'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',

View File

@ -181,6 +181,10 @@ final class DataSourceService
if ($membersSchema !== '') { if ($membersSchema !== '') {
$schemas[] = $membersSchema; $schemas[] = $membersSchema;
} }
$fenSchema = $this->router->fenDbName();
if ($fenSchema !== '') {
$schemas[] = $fenSchema;
}
$sql = 'SELECT schema_name FROM information_schema.schemata ORDER BY schema_name'; $sql = 'SELECT schema_name FROM information_schema.schemata ORDER BY schema_name';
$rows = $this->router->membersPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC); $rows = $this->router->membersPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);

View File

@ -38,6 +38,10 @@ final class DslCompiler
} }
[$leftRef, $rightRef] = $this->rewriteJoinRef($path['left'], $path['right']); [$leftRef, $rightRef] = $this->rewriteJoinRef($path['left'], $path['right']);
if ($this->isNormalizedJoin($left, $right)) {
$leftRef = 'LOWER(TRIM(COALESCE(' . $leftRef . ', \'\')))';
$rightRef = 'LOWER(TRIM(COALESCE(' . $rightRef . ', \'\')))';
}
$joins[] = 'INNER JOIN ' . $this->quoteSource($right) . ' AS ' . $this->alias($right) $joins[] = 'INNER JOIN ' . $this->quoteSource($right) . ' AS ' . $this->alias($right)
. ' ON ' . $leftRef . ' = ' . $rightRef; . ' ON ' . $leftRef . ' = ' . $rightRef;
} }
@ -52,7 +56,7 @@ final class DslCompiler
$params = []; $params = [];
$whereParts = []; $whereParts = [];
foreach ($ast['where'] as $predicate) { foreach ($ast['where'] as $predicate) {
$whereParts[] = $this->compilePredicate($predicate, $params); $whereParts[] = $this->compilePredicate($predicate, $params, $sources);
} }
$whereSql = $whereParts === [] ? '' : ' WHERE ' . implode(' AND ', $whereParts); $whereSql = $whereParts === [] ? '' : ' WHERE ' . implode(' AND ', $whereParts);
@ -70,19 +74,19 @@ final class DslCompiler
} }
/** @param list<mixed> &$params */ /** @param list<mixed> &$params */
private function compilePredicate(array $predicate, array &$params): string private function compilePredicate(array $predicate, array &$params, array $sources): string
{ {
if ($predicate['type'] === 'group') { if ($predicate['type'] === 'group') {
$parts = []; $parts = [];
foreach ($predicate['items'] as $item) { foreach ($predicate['items'] as $item) {
$parts[] = $this->compilePredicate($item, $params); $parts[] = $this->compilePredicate($item, $params, $sources);
} }
$sql = '(' . implode(' AND ', $parts) . ')'; $sql = '(' . implode(' AND ', $parts) . ')';
return $predicate['not'] ? 'NOT ' . $sql : $sql; return $predicate['not'] ? 'NOT ' . $sql : $sql;
} }
if ($predicate['type'] === 'filter') { if ($predicate['type'] === 'filter') {
$sql = $this->compileFilter($predicate['name']); $sql = $this->compileFilter($predicate['name'], $predicate['args'] ?? [], $params, $sources);
return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql; return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql;
} }
@ -135,7 +139,8 @@ final class DslCompiler
throw new AppError('dsl_compile', 'Unknown predicate type', ['type' => $predicate['type']]); throw new AppError('dsl_compile', 'Unknown predicate type', ['type' => $predicate['type']]);
} }
private function compileFilter(string $name): string /** @param list<mixed> $args @param list<mixed> &$params @param list<string> $sources */
private function compileFilter(string $name, array $args, array &$params, array $sources): string
{ {
if ($name === 'selected-renewal') { if ($name === 'selected-renewal') {
return $this->alias('renewals') . '.`selected` = 1'; return $this->alias('renewals') . '.`selected` = 1';
@ -149,6 +154,34 @@ final class DslCompiler
if ($name === 'primary-contact') { if ($name === 'primary-contact') {
return $this->alias('contacts') . '.`is_contact_1` = 1'; return $this->alias('contacts') . '.`is_contact_1` = 1';
} }
if ($name === 'selected') {
return 'COALESCE(' . $this->alias('advertisers') . '.`Selected`, 0) <> 0';
}
if ($name === 'pending-invoice') {
return $this->alias('invoices') . '.`status` = \'pending\'';
}
if ($name === 'selected-invoice') {
return $this->alias('invoices') . '.`id` IN (SELECT id FROM ' . $this->quoteSource('invoices') . ' WHERE 0=1)';
}
if ($name === 'invoice-ids') {
$ids = $this->numericFilterArgs($name, $args);
foreach ($ids as $id) {
$params[] = $id;
}
return $this->alias('invoices') . '.`id` IN (' . implode(',', array_fill(0, count($ids), '?')) . ')';
}
if ($name === 'page-in-issue') {
$issueId = $this->singleNumericFilterArg($name, $args);
$params[] = $issueId;
if (in_array('pages', $sources, true)) {
return $this->alias('pages') . '.`Issue` = ?';
}
$pagesTable = $this->quoteSource('pages');
return 'EXISTS (SELECT 1 FROM ' . $pagesTable . ' AS p_filter WHERE p_filter.`ID` = ' . $this->alias('articles') . '.`PageID` AND p_filter.`Issue` = ?)';
}
if ($name === 'ad-in-issue') {
return $this->compileAdInIssueFilter($this->singleNumericFilterArg($name, $args), $params, $sources);
}
if ($name === 'member-or-affiliate-or-parish-council') { if ($name === 'member-or-affiliate-or-parish-council') {
$acc = $this->alias('accounts'); $acc = $this->alias('accounts');
return sprintf( return sprintf(
@ -168,8 +201,8 @@ final class DslCompiler
$rightParts = explode('.', $right, 2); $rightParts = explode('.', $right, 2);
return [ return [
$this->alias($leftParts[0]) . '.`' . $leftParts[1] . '`', $this->alias($leftParts[0]) . '.`' . str_replace('`', '``', $leftParts[1]) . '`',
$this->alias($rightParts[0]) . '.`' . $rightParts[1] . '`', $this->alias($rightParts[0]) . '.`' . str_replace('`', '``', $rightParts[1]) . '`',
]; ];
} }
@ -182,7 +215,11 @@ final class DslCompiler
return $virtual; return $virtual;
} }
} }
return $this->alias($fieldRef['source']) . '.`' . $fieldRef['field'] . '`'; $sql = $this->metadata->fieldSql($fieldRef['source'], $fieldRef['field'], $this->alias($fieldRef['source']));
if ($sql === null) {
throw new AppError('dsl_compile', 'Unknown field', ['source' => $fieldRef['source'], 'field' => $fieldRef['field']]);
}
return $sql;
} }
private function alias(string $source): string private function alias(string $source): string
@ -192,11 +229,12 @@ final class DslCompiler
private function quoteSource(string $source): string private function quoteSource(string $source): string
{ {
if (str_contains($source, '.')) { $table = $this->metadata->sourceTable($source) ?? $source;
[$schema, $table] = explode('.', $source, 2); if (str_contains($table, '.')) {
[$schema, $table] = explode('.', $table, 2);
return '`' . $schema . '`.`' . $table . '`'; return '`' . $schema . '`.`' . $table . '`';
} }
return '`' . $source . '`'; return '`' . $table . '`';
} }
/** @param list<string> $sources */ /** @param list<string> $sources */
@ -213,7 +251,11 @@ final class DslCompiler
continue; continue;
} }
} }
$parts[] = $alias . '.`' . $field . '` AS `' . $source . '.' . $field . '`'; $fieldSql = $this->metadata->fieldSql($source, $field, $alias);
if ($fieldSql === null) {
throw new AppError('dsl_compile', 'Unknown field', ['source' => $source, 'field' => $field]);
}
$parts[] = $fieldSql . ' AS `' . $source . '.' . $field . '`';
} }
} }
if ($parts === []) { if ($parts === []) {
@ -222,6 +264,58 @@ final class DslCompiler
return implode(', ', $parts); return implode(', ', $parts);
} }
private function isNormalizedJoin(string $left, string $right): bool
{
return ($left === 'ads' && $right === 'advertisers') || ($left === 'advertisers' && $right === 'ads');
}
/** @param list<mixed> $args */
private function singleNumericFilterArg(string $name, array $args): int
{
$values = $this->numericFilterArgs($name, $args);
if (count($values) !== 1) {
throw new AppError('dsl_compile', sprintf('Filter %s requires exactly one numeric issue ID', $name));
}
return $values[0];
}
/** @param list<mixed> $args @return list<int> */
private function numericFilterArgs(string $name, array $args): array
{
if ($args === []) {
throw new AppError('dsl_compile', sprintf('Filter %s requires numeric arguments', $name));
}
$out = [];
foreach ($args as $arg) {
if (!is_int($arg) && !(is_string($arg) && ctype_digit($arg))) {
throw new AppError('dsl_compile', sprintf('Filter %s accepts numeric arguments only', $name));
}
$out[] = (int) $arg;
}
return $out;
}
/** @param list<mixed> &$params @param list<string> $sources */
private function compileAdInIssueFilter(int $issueId, array &$params, array $sources): string
{
$fenAds = $this->quoteSource('ads');
$fenPages = $this->quoteSource('pages');
$params[] = $issueId;
if (in_array('ads', $sources, true)) {
return 'EXISTS (SELECT 1 FROM ' . $fenPages . ' AS p_ad_issue WHERE p_ad_issue.`ID` = ' . $this->alias('ads') . '.`PageID` AND p_ad_issue.`Issue` = ?)';
}
if (in_array('pages', $sources, true)) {
return 'EXISTS (SELECT 1 FROM ' . $fenAds . ' AS ad_page_issue WHERE ad_page_issue.`PageID` = ' . $this->alias('pages') . '.`ID`) AND ' . $this->alias('pages') . '.`Issue` = ?';
}
if (in_array('advertisers', $sources, true)) {
return 'EXISTS (SELECT 1 FROM ' . $fenAds . ' AS ad_adv_issue INNER JOIN ' . $fenPages . ' AS p_adv_issue ON p_adv_issue.`ID` = ad_adv_issue.`PageID` WHERE LOWER(TRIM(COALESCE(ad_adv_issue.`Advertiser`, \'\'))) = LOWER(TRIM(COALESCE(' . $this->alias('advertisers') . '.`AdvertiserName`, \'\'))) AND p_adv_issue.`Issue` = ?)';
}
if (in_array('issues', $sources, true)) {
return $this->alias('issues') . '.`ID` = ? AND EXISTS (SELECT 1 FROM ' . $fenPages . ' AS p_issue_ad INNER JOIN ' . $fenAds . ' AS ad_issue ON ad_issue.`PageID` = p_issue_ad.`ID` WHERE p_issue_ad.`Issue` = ' . $this->alias('issues') . '.`ID`)';
}
return $this->alias('invoices') . '.`issue_id` = ?';
}
private function accountsVirtualFieldSql(string $field): ?string private function accountsVirtualFieldSql(string $field): ?string
{ {
$name = strtolower(trim($field)); $name = strtolower(trim($field));

View File

@ -16,6 +16,12 @@ final class DslValidator
'pending-renewal' => ['renewals'], 'pending-renewal' => ['renewals'],
'primary-contact' => ['contacts'], 'primary-contact' => ['contacts'],
'fen1-contact' => ['contacts'], 'fen1-contact' => ['contacts'],
'selected' => ['advertisers'],
'page-in-issue' => ['pages|articles'],
'ad-in-issue' => ['advertisers|ads|pages|issues|invoices'],
'pending-invoice' => ['invoices'],
'selected-invoice' => ['invoices'],
'invoice-ids' => ['invoices'],
'member-or-affiliate-or-parish-council' => ['accounts'], 'member-or-affiliate-or-parish-council' => ['accounts'],
]; ];
@ -97,19 +103,24 @@ final class DslValidator
if ($predicate['type'] === 'filter') { if ($predicate['type'] === 'filter') {
$name = $predicate['name']; $name = $predicate['name'];
if ($name === 'fen2-contact') {
$errors[] = 'Filter fen2-contact is not supported. Use fen1-contact.';
return;
}
if (!isset($this->filterSourceRequirements[$name])) { if (!isset($this->filterSourceRequirements[$name])) {
$errors[] = 'Unknown filter: ' . $name; $errors[] = 'Unknown filter: ' . $name;
return; return;
} }
foreach ($this->filterSourceRequirements[$name] as $requiredSource) { foreach ($this->filterSourceRequirements[$name] as $requiredSource) {
if (!in_array($requiredSource, $sources, true)) { $alternatives = explode('|', $requiredSource);
$hasRequiredSource = false;
foreach ($alternatives as $alternative) {
if (in_array($alternative, $sources, true)) {
$hasRequiredSource = true;
break;
}
}
if (!$hasRequiredSource) {
$errors[] = sprintf('Filter %s requires source %s', $name, $requiredSource); $errors[] = sprintf('Filter %s requires source %s', $name, $requiredSource);
} }
} }
$this->validateFilterArgs($predicate, $errors);
return; return;
} }
@ -129,6 +140,37 @@ final class DslValidator
} }
} }
/** @param list<string> &$errors */
private function validateFilterArgs(array $predicate, array &$errors): void
{
$name = (string) ($predicate['name'] ?? '');
$args = is_array($predicate['args'] ?? null) ? $predicate['args'] : [];
if (in_array($name, ['selected-renewal', 'pending-renewal', 'primary-contact', 'fen1-contact', 'selected', 'pending-invoice', 'selected-invoice', 'member-or-affiliate-or-parish-council'], true)) {
if ($args !== []) {
$errors[] = sprintf('Filter %s does not take arguments', $name);
}
return;
}
if (in_array($name, ['page-in-issue', 'ad-in-issue'], true)) {
if (count($args) !== 1 || !is_int($args[0])) {
$errors[] = sprintf('Filter %s requires exactly one numeric issue ID', $name);
}
return;
}
if ($name === 'invoice-ids') {
if ($args === []) {
$errors[] = 'Filter invoice-ids requires one or more numeric invoice IDs';
return;
}
foreach ($args as $arg) {
if (!is_int($arg)) {
$errors[] = 'Filter invoice-ids accepts numeric invoice IDs only';
return;
}
}
}
}
/** @param array{source:string,field:string} $fieldRef @param list<string> $sources @param list<string> &$errors */ /** @param array{source:string,field:string} $fieldRef @param list<string> $sources @param list<string> &$errors */
private function validateFieldRef(array $fieldRef, array $sources, array &$errors): void private function validateFieldRef(array $fieldRef, array $sources, array &$errors): void
{ {

View File

@ -18,4 +18,8 @@ interface SourceMetadataProvider
/** @return list<string> */ /** @return list<string> */
public function allKnownSources(): array; public function allKnownSources(): array;
public function sourceTable(string $source): ?string;
public function fieldSql(string $source, string $field, string $alias): ?string;
} }

View File

@ -242,9 +242,11 @@ final class DslParser
$filters = [ $filters = [
'selected-renewal', 'selected-renewal',
'pending-renewal', 'pending-renewal',
'selected',
'primary-contact', 'primary-contact',
'fen1-contact', 'fen1-contact',
'fen2-contact', 'pending-invoice',
'selected-invoice',
'member-or-affiliate-or-parish-council', 'member-or-affiliate-or-parish-council',
]; ];
$next = $this->peek(); $next = $this->peek();

View File

@ -15,4 +15,6 @@ interface DatabaseRouter
public function mailshotsDbName(): string; public function mailshotsDbName(): string;
public function membersDbName(): string; public function membersDbName(): string;
public function fenDbName(): string;
} }

View File

@ -12,6 +12,10 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
/** @var array<string, list<string>> */ /** @var array<string, list<string>> */
private array $builtInFields; private array $builtInFields;
/** @var array<string, string> */
private array $builtInTables;
/** @var array<string, array<string, string>> */
private array $fieldMap;
/** @var array<string, list<string>> */ /** @var array<string, list<string>> */
private array $sourceFieldsCache = []; private array $sourceFieldsCache = [];
@ -21,11 +25,93 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
public function __construct(DatabaseRouter $router) public function __construct(DatabaseRouter $router)
{ {
$this->router = $router; $this->router = $router;
$fen = $router->fenDbName();
$this->builtInFields = [ $this->builtInFields = [
'contacts' => ['id', 'account_id', 'first_name', 'last_name', 'contact_email_1', 'contact_email_2', 'is_contact_1', 'is_contact_2', 'is_fen_1', 'is_fen_2', 'is_public_contact'], 'contacts' => ['id', 'account_id', 'first_name', 'last_name', 'contact_email_1', 'contact_email_2', 'is_contact_1', 'is_contact_2', 'is_fen_1', 'is_fen_2', 'is_public_contact'],
'accounts' => ['id', 'name', 'account_type_id'], 'accounts' => ['id', 'name', 'account_type_id'],
'renewals' => ['id', 'account_id', 'status', 'selected', 'email'], 'renewals' => ['id', 'account_id', 'status', 'selected', 'email'],
'grants' => ['id', 'account_id', 'name', 'amount', 'applied_for', 'total_cost', 'grant_date', 'notes'], 'grants' => ['id', 'account_id', 'name', 'amount', 'applied_for', 'total_cost', 'grant_date', 'notes'],
'advertisers' => ['name', 'advertisername'],
'ads' => ['id', 'advertiser', 'adsize', 'size', 'price', 'issue', 'pageid', 'state', 'notes'],
'pages' => ['id', 'issue', 'page'],
'articles' => ['id', 'pageid', 'articlenumber', 'article', 'content', 'membername', 'author', 'dcn', 'articlewords', 'otherwords', 'othercontent', 'other'],
'issues' => ['id', 'issue', 'issuemonths', 'description'],
'invoices' => ['id', 'issue', 'issue_id', 'ad_id', 'invoice_number', 'invoice_date', 'due_date', 'invoice_page', 'invoice_size', 'invoice_price', 'status', 'payment_date', 'amount_paid', 'payment_method', 'payment_reference', 'notes', 'created_at', 'updated_at'],
];
$this->builtInTables = [
'contacts' => 'contacts',
'accounts' => 'accounts',
'renewals' => 'renewals',
'grants' => 'grants',
'advertisers' => $fen . '.advertisers',
'ads' => $fen . '.ads',
'pages' => $fen . '.Pages',
'articles' => $fen . '.Articles',
'issues' => $fen . '.Issues',
'invoices' => $fen . '.invoices',
];
$this->fieldMap = [
'advertisers' => [
'name' => 'AdvertiserName',
'advertisername' => 'AdvertiserName',
],
'ads' => [
'id' => 'ID',
'advertiser' => 'Advertiser',
'adsize' => 'AdSize',
'size' => 'AdSize',
'price' => 'Price',
'pageid' => 'PageID',
'state' => 'State',
'notes' => 'Notes',
],
'pages' => [
'id' => 'ID',
'issue' => 'Issue',
'page' => 'Page',
],
'articles' => [
'id' => 'ID',
'pageid' => 'PageID',
'articlenumber' => 'ArticleNumber',
'article' => 'ArticleNumber',
'content' => 'Content',
'membername' => 'MemberName',
'author' => 'Author',
'dcn' => 'DCN',
'articlewords' => 'ArticleWords',
'otherwords' => 'OtherWords',
'othercontent' => 'OtherContent',
'other' => 'OtherContent',
],
'issues' => [
'id' => 'ID',
'issue' => 'ID',
'issuemonths' => 'IssueMonths',
'description' => 'Description',
],
'invoices' => [
'id' => 'id',
'issue' => 'issue_id',
'issue_id' => 'issue_id',
'ad_id' => 'ad_id',
'invoice_number' => 'invoice_number',
'invoice_date' => 'invoice_date',
'due_date' => 'due_date',
'invoice_page' => 'invoice_page',
'invoice_size' => 'invoice_size',
'invoice_price' => 'invoice_price',
'status' => 'status',
'payment_date' => 'payment_date',
'amount_paid' => 'amount_paid',
'payment_method' => 'payment_method',
'payment_reference' => 'payment_reference',
'notes' => 'notes',
'created_at' => 'created_at',
'updated_at' => 'updated_at',
],
]; ];
$this->joinMap = [ $this->joinMap = [
@ -37,6 +123,20 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
'contacts|renewals' => ['left' => 'contacts.account_id', 'right' => 'renewals.account_id'], 'contacts|renewals' => ['left' => 'contacts.account_id', 'right' => 'renewals.account_id'],
'grants|accounts' => ['left' => 'grants.account_id', 'right' => 'accounts.id'], 'grants|accounts' => ['left' => 'grants.account_id', 'right' => 'accounts.id'],
'accounts|grants' => ['left' => 'accounts.id', 'right' => 'grants.account_id'], 'accounts|grants' => ['left' => 'accounts.id', 'right' => 'grants.account_id'],
'articles|pages' => ['left' => 'articles.PageID', 'right' => 'pages.ID'],
'pages|articles' => ['left' => 'pages.ID', 'right' => 'articles.PageID'],
'pages|issues' => ['left' => 'pages.Issue', 'right' => 'issues.ID'],
'issues|pages' => ['left' => 'issues.ID', 'right' => 'pages.Issue'],
'ads|pages' => ['left' => 'ads.PageID', 'right' => 'pages.ID'],
'pages|ads' => ['left' => 'pages.ID', 'right' => 'ads.PageID'],
'ads|advertisers' => ['left' => 'ads.Advertiser', 'right' => 'advertisers.AdvertiserName'],
'advertisers|ads' => ['left' => 'advertisers.AdvertiserName', 'right' => 'ads.Advertiser'],
'invoices|ads' => ['left' => 'invoices.ad_id', 'right' => 'ads.ID'],
'ads|invoices' => ['left' => 'ads.ID', 'right' => 'invoices.ad_id'],
'invoices|issues' => ['left' => 'invoices.issue_id', 'right' => 'issues.ID'],
'issues|invoices' => ['left' => 'issues.ID', 'right' => 'invoices.issue_id'],
'invoices|pages' => ['left' => 'invoices.issue_id', 'right' => 'pages.Issue'],
'pages|invoices' => ['left' => 'pages.Issue', 'right' => 'invoices.issue_id'],
]; ];
} }
@ -60,7 +160,10 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
public function sourceFields(string $source): array public function sourceFields(string $source): array
{ {
if (isset($this->builtInFields[$source])) { if (isset($this->builtInFields[$source])) {
$fields = $this->loadFieldsFromTable($source); if (isset($this->fieldMap[$source])) {
return $this->builtInFields[$source];
}
$fields = $this->loadFieldsFromTable($this->builtInTables[$source] ?? $source);
if ($source === 'accounts') { if ($source === 'accounts') {
foreach (['type', 'public_location', 'account_sector'] as $virtualField) { foreach (['type', 'public_location', 'account_sector'] as $virtualField) {
if (!in_array($virtualField, $fields, true)) { if (!in_array($virtualField, $fields, true)) {
@ -95,30 +198,59 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
public function allKnownSources(): array public function allKnownSources(): array
{ {
return ['contacts', 'accounts', 'renewals', 'grants']; return array_keys($this->builtInFields);
}
public function sourceTable(string $source): ?string
{
return $this->builtInTables[$source] ?? (str_contains($source, '.') ? $source : null);
}
public function fieldSql(string $source, string $field, string $alias): ?string
{
$source = trim($source);
$fieldKey = strtolower(trim($field));
if ($source === 'ads' && $fieldKey === 'issue') {
$pagesTable = $this->quoteSource($this->builtInTables['pages']);
return '(SELECT p_issue.`Issue` FROM ' . $pagesTable . ' AS p_issue WHERE p_issue.`ID` = ' . $alias . '.`PageID`)';
}
if (isset($this->fieldMap[$source])) {
$column = $this->fieldMap[$source][$fieldKey] ?? null;
return $column === null ? null : $alias . '.`' . str_replace('`', '``', $column) . '`';
}
return $alias . '.`' . str_replace('`', '``', $field) . '`';
} }
/** @return list<string> */ /** @return list<string> */
private function loadFieldsFromTable(string $table): array private function loadFieldsFromTable(string $tableSql): array
{ {
$table = trim($table); $tableSql = trim($tableSql);
if ($table === '') { if ($tableSql === '') {
return []; return [];
} }
if (isset($this->sourceFieldsCache[$table])) { if (isset($this->sourceFieldsCache[$tableSql])) {
return $this->sourceFieldsCache[$table]; return $this->sourceFieldsCache[$tableSql];
} }
$sql = 'SHOW COLUMNS FROM `' . str_replace('`', '``', $table) . '`'; $sql = 'SHOW COLUMNS FROM ' . $this->quoteSource($tableSql);
$rows = $this->router->membersPdo()->query($sql)->fetchAll(\PDO::FETCH_ASSOC); $rows = $this->router->membersPdo()->query($sql)->fetchAll(\PDO::FETCH_ASSOC);
$fields = array_values(array_filter( $fields = array_values(array_filter(
array_map(static fn(array $row): string => trim((string) ($row['Field'] ?? '')), $rows), array_map(static fn(array $row): string => trim((string) ($row['Field'] ?? '')), $rows),
static fn(string $v): bool => $v !== '' static fn(string $v): bool => $v !== ''
)); ));
if ($fields === []) { if ($fields === []) {
throw new \RuntimeException('Source "' . $table . '" has no readable columns.'); throw new \RuntimeException('Source "' . $tableSql . '" has no readable columns.');
} }
$this->sourceFieldsCache[$table] = $fields; $this->sourceFieldsCache[$tableSql] = $fields;
return $fields; return $fields;
} }
private function quoteSource(string $source): string
{
if (str_contains($source, '.')) {
[$schema, $table] = explode('.', $source, 2);
return '`' . str_replace('`', '``', $schema) . '`.`' . str_replace('`', '``', $table) . '`';
}
return '`' . str_replace('`', '``', $source) . '`';
}
} }

View File

@ -16,6 +16,8 @@ final class PdoDatabaseRouter implements DatabaseRouter
private string $membersDbName; private string $membersDbName;
private string $fenDbName;
/** @param array<string, string> $config */ /** @param array<string, string> $config */
public function __construct(array $config) public function __construct(array $config)
{ {
@ -26,6 +28,7 @@ final class PdoDatabaseRouter implements DatabaseRouter
$this->mailshotsDbName = self::required($config, 'MAILSHOTS_REMOTE_MYSQL_DB'); $this->mailshotsDbName = self::required($config, 'MAILSHOTS_REMOTE_MYSQL_DB');
$this->membersDbName = self::required($config, 'MEMBERS_REMOTE_MYSQL_DB'); $this->membersDbName = self::required($config, 'MEMBERS_REMOTE_MYSQL_DB');
$this->fenDbName = self::required($config, 'FEN_REMOTE_MYSQL_DB');
$common = [ $common = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
@ -68,6 +71,11 @@ final class PdoDatabaseRouter implements DatabaseRouter
return $this->membersDbName; return $this->membersDbName;
} }
public function fenDbName(): string
{
return $this->fenDbName;
}
/** @param array<string, string> $config */ /** @param array<string, string> $config */
private static function required(array $config, string $key): string private static function required(array $config, string $key): string
{ {

View File

@ -32,6 +32,7 @@ $dbConfig = [
'MYSQL_PASSWORD' => $getOption('db_password'), 'MYSQL_PASSWORD' => $getOption('db_password'),
'MAILSHOTS_REMOTE_MYSQL_DB' => $getOption('mailshots_db_name'), 'MAILSHOTS_REMOTE_MYSQL_DB' => $getOption('mailshots_db_name'),
'MEMBERS_REMOTE_MYSQL_DB' => $getOption('members_db_name'), 'MEMBERS_REMOTE_MYSQL_DB' => $getOption('members_db_name'),
'FEN_REMOTE_MYSQL_DB' => $getOption('fen_db_name'),
]; ];
$wp = new ProductionWordPressFacade(); $wp = new ProductionWordPressFacade();

View File

@ -302,7 +302,7 @@ DSL filters include:
* `selected-renewal` * `selected-renewal`
* `pending-renewal` * `pending-renewal`
* `fen1-contact` * `fen1-contact`
* `fen2-contact` * `primary-contact`
* `member-or-affiliate-or-parish-council` * `member-or-affiliate-or-parish-council`
`selected-renewal` uses saved field values (`renewals.selected = true`). `selected-renewal` uses saved field values (`renewals.selected = true`).

View File

@ -76,3 +76,39 @@ test('data sources: DSL builder round-trip preserves complex representable DSL',
} }
} }
}); });
test('data sources: DSL builder round-trip preserves FEN built-in filters', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ds_fen_roundtrip_${uniq}`;
const dsl =
'invoices and ads and advertisers where invoice-ids(10, 11) and ad-in-issue(202605) and selected';
try {
await ensureDataSource(request, dsName, dsl);
await page.goto(adminPath('feca-mailshot-data-sources'));
await expect(page.getByRole('heading', { name: 'Mailshot Data Sources' })).toBeVisible();
const row = page.locator('tr', {
has: page.getByRole('cell', { name: dsName })
}).first();
await expect(row).toBeVisible();
await row.getByRole('link', { name: 'Edit' }).click();
await expect(page.locator('#ds-editor-modal')).toBeVisible();
await expect(page.locator('#ds_dsl')).toHaveValue(dsl);
await page.locator('#ds-build-dsl-open').click();
await expect(page.locator('#ds-builder-modal')).toBeVisible();
await expect(page.locator('#ds-builder-output')).toHaveValue(dsl);
await page.locator('#ds-builder-apply').click();
await expect(page.locator('#ds_dsl')).toHaveValue(dsl);
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName] });
} catch {
// best effort
}
}
});

View File

@ -22,6 +22,7 @@ $dbConfig = [
'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'),
'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'),
'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'),
'FEN_REMOTE_MYSQL_DB' => Env::require('FEN_REMOTE_MYSQL_DB'),
]; ];
$wp = new FixtureWordPressFacade(); $wp = new FixtureWordPressFacade();

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
$path = ltrim((string) ($_GET['path'] ?? ''), '/');
if ($path === '' || str_contains($path, '..') || !str_starts_with($path, 'assets/vendor/')) {
http_response_code(404);
echo 'Asset not found';
exit;
}
$base = realpath(dirname(__DIR__, 2) . '/feca_mailshots_plugin');
$file = $base === false ? false : realpath($base . '/' . $path);
if ($base === false || $file === false || !str_starts_with($file, $base . DIRECTORY_SEPARATOR) || !is_file($file)) {
http_response_code(404);
echo 'Asset not found';
exit;
}
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
$types = [
'css' => 'text/css; charset=utf-8',
'js' => 'application/javascript; charset=utf-8',
];
header('Content-Type: ' . ($types[$ext] ?? 'application/octet-stream'));
readfile($file);

View File

@ -85,3 +85,10 @@ if (!function_exists('admin_url')) {
return '/wp-admin/' . ltrim($path, '/'); return '/wp-admin/' . ltrim($path, '/');
} }
} }
if (!function_exists('plugins_url')) {
function plugins_url(string $path = '', string $plugin = ''): string
{
return '/plugin-assets.php?path=' . rawurlencode(ltrim($path, '/'));
}
}

View File

@ -25,6 +25,7 @@ $dbConfig = [
'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'),
'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'),
'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_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); $router = new PdoDatabaseRouter($dbConfig);

View File

@ -33,6 +33,7 @@ $dbConfig = [
'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'),
'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'),
'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_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); $router = new PdoDatabaseRouter($dbConfig);

View File

@ -17,6 +17,7 @@ $dbConfig = [
'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'),
'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'),
'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'), 'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_REMOTE_MYSQL_DB'),
'FEN_REMOTE_MYSQL_DB' => Env::require('FEN_REMOTE_MYSQL_DB'),
]; ];
$wp = new FixtureWordPressFacade(); $wp = new FixtureWordPressFacade();

View File

@ -29,6 +29,7 @@ $dbConfig = [
'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'),
'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'),
'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_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); $router = new PdoDatabaseRouter($dbConfig);

View File

@ -71,6 +71,7 @@ $dbConfig = [
'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'), 'MYSQL_PASSWORD' => Env::require('REMOTE_MYSQL_PASSWORD'),
'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'), 'MAILSHOTS_REMOTE_MYSQL_DB' => Env::require('MAILSHOTS_REMOTE_MYSQL_DB'),
'MEMBERS_REMOTE_MYSQL_DB' => Env::require('MEMBERS_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); $router = new PdoDatabaseRouter($dbConfig);

View File

@ -41,6 +41,18 @@ final class FakeMetadataProvider implements SourceMetadataProvider
'accounts|renewals' => ['left' => 'accounts.ID', 'right' => 'renewals.account_id'], 'accounts|renewals' => ['left' => 'accounts.ID', 'right' => 'renewals.account_id'],
'renewals|contacts' => ['left' => 'renewals.account_id', 'right' => 'contacts.Accountid'], 'renewals|contacts' => ['left' => 'renewals.account_id', 'right' => 'contacts.Accountid'],
'contacts|renewals' => ['left' => 'contacts.Accountid', 'right' => 'renewals.account_id'], 'contacts|renewals' => ['left' => 'contacts.Accountid', 'right' => 'renewals.account_id'],
'articles|pages' => ['left' => 'articles.PageID', 'right' => 'pages.ID'],
'pages|articles' => ['left' => 'pages.ID', 'right' => 'articles.PageID'],
'pages|issues' => ['left' => 'pages.Issue', 'right' => 'issues.ID'],
'issues|pages' => ['left' => 'issues.ID', 'right' => 'pages.Issue'],
'ads|pages' => ['left' => 'ads.PageID', 'right' => 'pages.ID'],
'pages|ads' => ['left' => 'pages.ID', 'right' => 'ads.PageID'],
'ads|advertisers' => ['left' => 'ads.Advertiser', 'right' => 'advertisers.AdvertiserName'],
'advertisers|ads' => ['left' => 'advertisers.AdvertiserName', 'right' => 'ads.Advertiser'],
'invoices|ads' => ['left' => 'invoices.ad_id', 'right' => 'ads.ID'],
'ads|invoices' => ['left' => 'ads.ID', 'right' => 'invoices.ad_id'],
'invoices|issues' => ['left' => 'invoices.issue_id', 'right' => 'issues.ID'],
'issues|invoices' => ['left' => 'issues.ID', 'right' => 'invoices.issue_id'],
]; ];
return $pairs[$left . '|' . $right] ?? null; return $pairs[$left . '|' . $right] ?? null;
} }
@ -49,4 +61,29 @@ final class FakeMetadataProvider implements SourceMetadataProvider
{ {
return array_keys($this->fields); return array_keys($this->fields);
} }
public function sourceTable(string $source): ?string
{
return $source;
}
public function fieldSql(string $source, string $field, string $alias): ?string
{
if (!isset($this->fields[$source]) || !in_array($field, $this->fields[$source], true)) {
return null;
}
if ($source === 'ads' && $field === 'issue') {
return '(SELECT p_issue.`Issue` FROM `pages` AS p_issue WHERE p_issue.`ID` = ' . $alias . '.`PageID`)';
}
$map = [
'advertisers' => ['name' => 'AdvertiserName', 'advertisername' => 'AdvertiserName'],
'ads' => ['id' => 'ID', 'advertiser' => 'Advertiser', 'adsize' => 'AdSize', 'size' => 'AdSize', 'price' => 'Price', 'pageid' => 'PageID', 'state' => 'State', 'notes' => 'Notes'],
'pages' => ['id' => 'ID', 'issue' => 'Issue', 'page' => 'Page'],
'articles' => ['id' => 'ID', 'pageid' => 'PageID', 'articlenumber' => 'ArticleNumber', 'article' => 'ArticleNumber', 'content' => 'Content', 'membername' => 'MemberName', 'author' => 'Author', 'dcn' => 'DCN', 'articlewords' => 'ArticleWords', 'otherwords' => 'OtherWords', 'othercontent' => 'OtherContent', 'other' => 'OtherContent'],
'issues' => ['id' => 'ID', 'issue' => 'ID', 'issuemonths' => 'IssueMonths', 'description' => 'Description'],
'invoices' => ['id' => 'id', 'issue' => 'issue_id', 'issue_id' => 'issue_id', 'ad_id' => 'ad_id', 'invoice_number' => 'invoice_number', 'status' => 'status'],
];
$column = $map[$source][$field] ?? $field;
return $alias . '.`' . $column . '`';
}
} }

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
require_once __DIR__ . '/FakeMetadataProvider.php';
use FecaMailshots\Application\DslCompiler;
use FecaMailshots\Application\DslValidator;
use FecaMailshots\Domain\DslParser;
use FecaMailshots\Tests\Unit\FakeMetadataProvider;
$metadata = new FakeMetadataProvider([
'advertisers' => ['name', 'advertisername'],
'ads' => ['id', 'advertiser', 'adsize', 'size', 'price', 'issue', 'pageid', 'state', 'notes'],
'pages' => ['id', 'issue', 'page'],
'articles' => ['id', 'pageid', 'articlenumber', 'article', 'content', 'membername', 'author', 'dcn', 'articlewords', 'otherwords', 'othercontent', 'other'],
'issues' => ['id', 'issue', 'issuemonths', 'description'],
'invoices' => ['id', 'issue', 'issue_id', 'ad_id', 'invoice_number', 'status'],
]);
$parser = new DslParser();
$validator = new DslValidator($metadata);
$compiler = new DslCompiler($metadata);
$cases = [
'ads and pages where ad-in-issue(202605)' => [
'needle' => 'p_ad_issue.`Issue` = ?',
'params' => [202605],
],
'articles and pages where page-in-issue(202605)' => [
'needle' => 's_pages.`Issue` = ?',
'params' => [202605],
],
'advertisers and ads where selected' => [
'needle' => 'COALESCE(s_advertisers.`Selected`, 0) <> 0',
'params' => [],
],
'invoices and ads where invoice-ids(10, 11)' => [
'needle' => 's_invoices.`id` IN (?,?)',
'params' => [10, 11],
],
];
foreach ($cases as $dsl => $expect) {
$ast = $parser->parse($dsl);
$validation = $validator->validate($ast);
if (($validation['errors'] ?? []) !== []) {
fwrite(STDERR, "Expected valid DSL {$dsl}: " . json_encode($validation['errors']) . "\n");
exit(1);
}
$compiled = $compiler->compile($ast);
$sql = (string) ($compiled['sql'] ?? '');
if (strpos($sql, $expect['needle']) === false) {
fwrite(STDERR, "Expected SQL for {$dsl} to contain {$expect['needle']}\n{$sql}\n");
exit(1);
}
if (($compiled['params'] ?? []) !== $expect['params']) {
fwrite(STDERR, "Unexpected params for {$dsl}: " . json_encode($compiled['params'] ?? []) . "\n");
exit(1);
}
}
echo "DSL FEN built-in tests passed\n";

View File

@ -14,6 +14,12 @@ $metadata = new FakeMetadataProvider([
'contacts' => ['ID', 'Accountid', 'Last', 'Email', 'FENContact1'], 'contacts' => ['ID', 'Accountid', 'Last', 'Email', 'FENContact1'],
'accounts' => ['ID', 'Name', 'Type'], 'accounts' => ['ID', 'Name', 'Type'],
'renewals' => ['id', 'account_id', 'status', 'selected'], 'renewals' => ['id', 'account_id', 'status', 'selected'],
'advertisers' => ['name', 'advertisername'],
'ads' => ['id', 'advertiser', 'adsize', 'size', 'price', 'issue', 'pageid', 'state', 'notes'],
'pages' => ['id', 'issue', 'page'],
'articles' => ['id', 'pageid', 'articlenumber', 'article', 'content', 'membername', 'author', 'dcn', 'articlewords', 'otherwords', 'othercontent', 'other'],
'issues' => ['id', 'issue', 'issuemonths', 'description'],
'invoices' => ['id', 'issue', 'issue_id', 'ad_id', 'invoice_number', 'status'],
'members.ExcludedAccounts' => ['ExcludedAccount'], 'members.ExcludedAccounts' => ['ExcludedAccount'],
]); ]);
@ -34,6 +40,26 @@ $cases = [
'dsl' => 'contacts and members.ExcludedAccounts', 'dsl' => 'contacts and members.ExcludedAccounts',
'expectValid' => false, 'expectValid' => false,
], ],
[
'dsl' => 'ads and pages where ad-in-issue(202605)',
'expectValid' => true,
],
[
'dsl' => 'articles and pages where page-in-issue(202605)',
'expectValid' => true,
],
[
'dsl' => 'invoices and ads where invoice-ids(10, 11)',
'expectValid' => true,
],
[
'dsl' => 'ads where page-in-issue(202605)',
'expectValid' => false,
],
[
'dsl' => "invoices where invoice-ids('abc')",
'expectValid' => false,
],
]; ];
$failures = []; $failures = [];