implementation of fen database
This commit is contained in:
parent
e02696ea2f
commit
4a667a8c35
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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";};}';
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) . '`';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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, '/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 . '`';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue