Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions app/renderer/sql-completion.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@
const joins=['JOIN','LEFT JOIN','INNER JOIN','CROSS JOIN','RIGHT JOIN','FULL JOIN'];
const followingSource=['WHERE',...joins,'GROUP BY','ORDER BY','LIMIT'];
const followingPredicate=['AND','OR','GROUP BY','ORDER BY','LIMIT'];
function betweenContext(tokens,scopeStart){
const pending=[];let caseLevel=0;
for(let index=0;index<tokens.length;index++){
const token=tokens[index];if(token.start<scopeStart||token.quoted||tokens[index-1]?.value==='.')continue;
if(token.value===')'){for(let i=pending.length-1;i>=0;i--)if(pending[i].depth>token.depth)pending.splice(i,1);}
if(is(token,'CASE'))caseLevel++;
else if(is(token,'END','WHEN','THEN','ELSE')){
for(let i=pending.length-1;i>=0;i--)if(pending[i].depth===token.depth&&pending[i].caseLevel===caseLevel)pending.splice(i,1);
if(is(token,'END'))caseLevel=Math.max(0,caseLevel-1);
}
else if(is(token,'BETWEEN'))pending.push({depth:token.depth,caseLevel});
else if(is(token,'AND')){const at=pending.findLastIndex(bound=>bound.depth===token.depth&&bound.caseLevel===caseLevel);if(at>=0)pending.splice(at,1);}
}
return pending.at(-1);
}
function caseContext(tokens,scopeStart){
const stack=[];
for(let index=0;index<tokens.length;index++){
Expand Down Expand Up @@ -142,13 +157,15 @@
const aliases=!windowScope&&['ORDER','HAVING'].includes(clause)?outputAliases(allSame,position):[];
const expression=[...aliases,...columnNames,...funcs,'CASE','NULL'];
const activeCase=caseContext(tokens,scopeStart),caseLevel=activeCase?.depth===parsed.depth;
const between=betweenContext(tokens,scopeStart),needsBetweenAnd=between?.depth===parsed.depth&&operand;
const frame=windowScope&&['ROWS','RANGE','GROUPS'].includes(clause);
const frameTokens=frame?local.slice(local.findLastIndex(t=>is(t,'ROWS','RANGE','GROUPS'))+1):[];
const frameBetween=frameTokens.some(t=>is(t,'BETWEEN')),frameEnd=frameTokens.some(t=>is(t,'AND'));
let candidates=[];
if(qualifier){const source=uniqueSources.find(s=>(s.alias||s.table.name).toLowerCase()===qualifier.toLowerCase())||(!uniqueSources.length&&tables.has(qualifier.toLowerCase())?{table:tables.get(qualifier.toLowerCase())}:null);candidates=columns(source).map(spelling);}
else if(needsBetweenAnd)candidates=['AND'];
else if(caseLevel&&upper==='CASE')candidates=['WHEN',...expression];
else if(caseLevel&&['WHEN','THEN','ELSE'].includes(upper))candidates=[...expression,...(upper==='WHEN'?['NOT','EXISTS']:[])];
else if(upper==='BETWEEN'||caseLevel&&['WHEN','THEN','ELSE'].includes(upper))candidates=[...expression,...(upper==='WHEN'?['NOT','EXISTS']:[])];
else if(caseLevel&&operand){
if(activeCase.phase==='base')candidates=['WHEN'];
else if(activeCase.phase==='WHEN')candidates=['THEN','AND','OR','IS','IN','LIKE','BETWEEN'];
Expand Down Expand Up @@ -193,7 +210,7 @@
let matches=[...new Set(candidates)].filter(c=>!typed||c.replace(/^"/,'').toLowerCase().startsWith(typed.toLowerCase()));
// Never choose an unrelated first table or column when the next token is ambiguous.
if(!typed&&matches.length>1){if(upper==='SELECT')matches=['*'];else if(['LEFT','RIGHT','FULL'].includes(upper))matches=['JOIN'];else return null;}
if(!typed&&operand&&!qualifier&&!caseLevel){if(clause==='SELECT')matches=!hasFrom?['FROM']:[];else if(clause==='JOIN'&&currentJoin)matches=['ON '+currentJoin];else return null;}
if(!typed&&operand&&!qualifier&&!caseLevel&&!needsBetweenAnd){if(clause==='SELECT')matches=!hasFrom?['FROM']:[];else if(clause==='JOIN'&&currentJoin)matches=['ON '+currentJoin];else return null;}
let top=matches[0];if(!top)return null;
if(mode!=='word'){
const completed=top.toUpperCase();
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
"test:workshop": "node scripts/test-home-workshop.cjs",
"test:interface-details": "node scripts/test-interface-details.cjs",
"editor:build": "node scripts/build-sql-editor.cjs",
"test:editor": "node --test scripts/test-editor-policy.cjs scripts/test-sql-completion-context.cjs scripts/test-sql-output-aliases.cjs scripts/test-sql-case-completion.cjs scripts/test-sql-highlight.cjs && node scripts/test-editor-ui.cjs && node scripts/test-sql-completion-ui.cjs && node scripts/test-workspace-responsiveness.cjs",
"test:editor": "node --test scripts/test-editor-policy.cjs scripts/test-sql-completion-context.cjs scripts/test-sql-output-aliases.cjs scripts/test-sql-case-completion.cjs scripts/test-sql-between-completion.cjs scripts/test-sql-highlight.cjs && node scripts/test-editor-ui.cjs && node scripts/test-sql-completion-ui.cjs && node scripts/test-workspace-responsiveness.cjs",
"test:layout": "node --test scripts/test-window-layout.cjs scripts/test-interface-scale.cjs",
"postinstall": "node scripts/prepare-runtime.cjs",
"docs:assets": "node scripts/build-repository-assets.cjs",
Expand Down
38 changes: 38 additions & 0 deletions scripts/test-sql-between-completion.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use strict';
const assert=require('node:assert/strict'),{test}=require('node:test'),{create}=require('../app/renderer/sql-completion.js');
const schema=[{name:'sales',columns:['amount','threshold','and_amount','status','then_amount']}];
function suggest(marked,mode='next3'){const position=marked.indexOf('¦');assert(position>=0);return create()({text:marked.replace('¦',''),position,mode,schema});}
const top=(...args)=>suggest(...args)?.top??null;
test('an unfinished BETWEEN bound requires AND rather than a CASE transition',()=>{
for(const mode of ['word','next1','next3','line'])for(const bound of ['10','threshold','COALESCE(threshold,10)','(5+5)',"'A'",'(SELECT MAX(amount) FROM sales)']){
assert.equal(top('SELECT CASE WHEN amount BETWEEN '+bound+' TH¦ FROM sales',mode),null,mode+': '+bound);
assert.equal(top('SELECT CASE WHEN amount BETWEEN '+bound+' AN¦ FROM sales',mode),'AND',mode+': '+bound);
}
for(const sql of ['SELECT * FROM sales WHERE amount BETWEEN 10 OR¦','SELECT CASE WHEN amount NOT BETWEEN 10 TH¦ FROM sales','SELECT CASE WHEN amount BETWEEN 10 EN¦ FROM sales'])assert.equal(top(sql),null,sql);
});
test('both bounds still use normal expression completion and a completed range permits THEN',()=>{
for(const [marked,expected]of [
['SELECT CASE WHEN amount BETWEEN th¦ AND 100 THEN 1 END FROM sales','threshold'],
['SELECT CASE WHEN amount BETWEEN 10 + th¦ AND 100 THEN 1 END FROM sales','threshold'],
['SELECT CASE WHEN amount BETWEEN 10 AND th¦ THEN 1 END FROM sales','threshold'],
['SELECT CASE WHEN amount BETWEEN 10 AND 100 TH¦ FROM sales','THEN'],
['SELECT CASE WHEN amount BETWEEN 10 AND 100 AND status IS NULL TH¦ FROM sales','THEN'],
['SELECT CASE WHEN amount BETWEEN CASE WHEN status IS NULL THEN 10 ELSE 20 END AN¦ 100 THEN 1 END FROM sales','AND'],
['SELECT CASE WHEN amount BETWEEN 10 AND CASE WHEN status IS NULL THEN 100 ELSE 200 END TH¦ FROM sales','THEN'],
['SELECT CASE WHEN amount BETWEEN COALESCE((CASE WHEN status IS NULL THEN 10 ELSE 20 END),0) AN¦ FROM sales','AND']
])assert.equal(top(marked),expected,marked);
});
test('BETWEEN separators ignore AND in nested queries, CASE branches, strings and comments',()=>{
for(const sql of ['SELECT CASE WHEN amount BETWEEN (SELECT MAX(amount) FROM sales WHERE amount>0 AND status IS NULL) AN¦ FROM sales','SELECT CASE WHEN amount BETWEEN CASE WHEN amount>0 AND status IS NULL THEN 10 ELSE 20 END AN¦ FROM sales',"SELECT CASE WHEN status BETWEEN 'AND' AN¦ FROM sales",'SELECT CASE WHEN amount BETWEEN 10 /* AND */ AN¦ FROM sales'])assert.equal(top(sql),'AND',sql);
assert.equal(top('SELECT CASE WHEN amount BETWEEN 10 THEN 1 ELSE CASE WHEN amount>0 TH¦ FROM sales'),'THEN');
assert.equal(top('SELECT * FROM sales WHERE amount BETWEEN 1 AND 2; SELECT CASE WHEN amount>0 TH¦ FROM sales'),'THEN');
assert.equal(top('SELECT CASE WHEN amount BETWEEN 10 AND (SELECT CASE WHEN amount>0 TH¦ FROM sales) END FROM sales'),'THEN');
});
test('accepted BETWEEN and CASE completions execute without changing SQL execution',async()=>{
const SQL=await require('sql.js')(),db=new SQL.Database();try{
db.run("CREATE TABLE sales(amount REAL,threshold REAL,status TEXT); INSERT INTO sales VALUES(15,10,NULL),(50,20,'paid');");
for(const marked of ['SELECT CASE WHEN amount BETWEEN 10 AN¦ 100 THEN 1 ELSE 0 END FROM sales;','SELECT CASE WHEN amount BETWEEN 10 AND 100 TH¦ 1 ELSE 0 END FROM sales;','SELECT CASE WHEN amount BETWEEN CASE WHEN status IS NULL THEN 10 ELSE 20 END AN¦ 100 THEN 1 ELSE 0 END FROM sales;']){
const result=suggest(marked);assert(result);const position=marked.indexOf('¦'),text=marked.replace('¦',''),sql=text.slice(0,result.start)+result.top+text.slice(position);assert.equal(db.exec(sql)[0].values.length,2,sql);
}
}finally{db.close();}
});
2 changes: 2 additions & 0 deletions scripts/test-sql-completion-ui.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ LEFT JOIN installments AS i ON l.loan_id = i.loan_id`;
const completedCase=await accept(marked,expected,value+': native Tab completes the correct CASE branch keyword '+expected);
await app.evaluate('window.__pg.run('+JSON.stringify(completedCase)+')');check(await app.evaluate('!window.__pg.lastErr()&&window.__pg.resultSets()[0].values[0][0]===1'),value+': completed CASE executes with its original suffix');
}
await editor('SELECT CASE WHEN due_amount BETWEEN 10 TH¦ FROM installments');await wait(120);check(await app.evaluate('!document.querySelector("#sqlEditor .ghost")&&!document.getElementById("editorSuggestion").textContent'),value+': an incomplete BETWEEN never offers THEN');
const completedRange=await accept('SELECT CASE WHEN due_amount BETWEEN 10 AN¦ 200 THEN 1 ELSE 0 END FROM installments','AND',value+': BETWEEN requires its AND separator');await app.evaluate('window.__pg.run('+JSON.stringify(completedRange)+')');check(await app.evaluate('!window.__pg.lastErr()&&window.__pg.resultSets()[0].values[0][0]===1'),value+': completed BETWEEN executes successfully');
}
await mode('next3');await accept('SELECT AVG(due_amount) AS "average amount" FROM installments ORDER BY aver¦ DESC','"average amount"','Quoted output aliases are accepted as complete identifiers');
await editor('SELECT SUM(due_amount) AS total_revenue FROM installments; SELECT * FROM customers ORDER BY total_rev¦');await wait(150);check(await app.evaluate('!document.querySelector("#sqlEditor .ghost")&&!document.getElementById("editorSuggestion").textContent'),'Output aliases from a previous statement are never suggested in a new query');
Expand Down
Loading