Skip to content
Merged
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
24 changes: 23 additions & 1 deletion app/renderer/sql-completion.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@
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 caseContext(tokens,scopeStart){
const stack=[];
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===')'){while(stack.length&&stack.at(-1).depth>token.depth)stack.pop();}
if(is(token,'CASE'))stack.push({depth:token.depth,phase:'base'});
else if(stack.at(-1)?.depth===token.depth){
if(is(token,'END'))stack.pop();
else if(is(token,'WHEN','THEN','ELSE'))stack.at(-1).phase=token.upper;
}
}
return stack.at(-1);
}
function outputAliases(tokens,position){
// Projection names belong to one SELECT, never a CTE, sibling or UNION arm.
if(tokens.some(token=>is(token,'UNION','INTERSECT','EXCEPT')))return [];
Expand Down Expand Up @@ -128,11 +141,20 @@
const operand=last&&(wildcard||last.quoted||last.value===')'||is(last,'END','NULL','TRUE','FALSE')||/^[\p{L}\p{N}_]/u.test(last.value)&&!reserved.has(last.upper));
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 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(caseLevel&&upper==='CASE')candidates=['WHEN',...expression];
else if(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'];
else if(activeCase.phase==='THEN')candidates=['WHEN','ELSE','END'];
else if(activeCase.phase==='ELSE')candidates=['END'];
}
else if(['FROM','JOIN','INTO','UPDATE'].includes(upper))candidates=schema.map(t=>spelling(t.name));
else if(['ORDER','GROUP','PARTITION'].includes(upper))candidates=['BY'];
else if(['LEFT','RIGHT','FULL'].includes(upper))candidates=['JOIN','OUTER JOIN'];
Expand Down Expand Up @@ -171,7 +193,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){if(clause==='SELECT')matches=!hasFrom?['FROM']:[];else if(clause==='JOIN'&&currentJoin)matches=['ON '+currentJoin];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;}
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-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-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
73 changes: 73 additions & 0 deletions scripts/test-sql-case-completion.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use strict';
const assert=require('node:assert/strict'),{test}=require('node:test');
const {create}=require('../app/renderer/sql-completion.js');
const schema=[{name:'sales',columns:['amount','status','threshold','elsewhere','end_date','when_created','end','when']}];
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('searched CASE follows WHEN, THEN, ELSE and END instead of unrelated schema columns',()=>{
for(const mode of ['word','next1','next3','line'])for(const [marked,expected] of [
['SELECT CASE WH¦ FROM sales','WHEN'],
['SELECT CASE WHEN amount>100 TH¦ FROM sales','THEN'],
['SELECT CASE WHEN amount>100 THEN 1 EL¦ FROM sales','ELSE'],
['SELECT CASE WHEN amount>100 THEN 1 EN¦ FROM sales','END'],
['SELECT CASE WHEN amount>100 THEN 1 ELSE 0 EN¦ FROM sales','END'],
['SELECT CASE WHEN amount>100 THEN 1 WH¦ FROM sales','WHEN'],
['SELECT CASE WHEN amount>100 THEN 1 WHEN amount>10 TH¦ FROM sales','THEN'],
["SELECT CASE WHEN status='paid' TH¦ FROM sales",'THEN'],
['SELECT CASE WHEN amount IS NULL TH¦ FROM sales','THEN'],
['SELECT CASE WHEN amount BETWEEN 1 AND 100 TH¦ FROM sales','THEN'],
['SELECT CASE WHEN (amount>100 AND status IS NOT NULL) TH¦ FROM sales','THEN']
])assert.equal(top(marked,mode),expected,mode+': '+marked);
});

test('simple CASE and nested CASE expressions keep independent states',()=>{
for(const [marked,expected]of [
['SELECT CASE status WH¦ FROM sales','WHEN'],
["SELECT CASE status WHEN 'paid' TH¦ FROM sales",'THEN'],
["SELECT CASE status WHEN 'paid' THEN 1 ELSE 0 EN¦ FROM sales",'END'],
['SELECT CASE WHEN amount>0 THEN CASE status WH¦ FROM sales','WHEN'],
["SELECT CASE WHEN amount>0 THEN CASE status WHEN 'paid' THEN 1 END EL¦ FROM sales",'ELSE'],
['SELECT CASE WHEN CASE WHEN amount>0 THEN 1 ELSE 0 END=1 TH¦ FROM sales','THEN'],
['SELECT COALESCE(CASE WHEN amount>0 TH¦,0) FROM sales','THEN'],
['SELECT CASE COALESCE(status,\'unknown\') WH¦ FROM sales','WHEN']
])assert.equal(top(marked),expected,marked);
});

test('expressions inside CASE retain columns, aliases, functions and condition keywords',()=>{
for(const [marked,expected]of [
['SELECT CASE WHEN am¦ > 10 THEN 1 END FROM sales','amount'],
['SELECT CASE WHEN s.am¦ > 10 THEN 1 END FROM sales s','amount'],
['SELECT CASE WHEN amount>10 THEN am¦ END FROM sales','amount'],
['SELECT CASE WHEN amount>10 THEN 1 ELSE COU¦ FROM sales','COUNT'],
['SELECT CASE WHEN COALESCE(st¦,\'unknown\')=\'paid\' THEN 1 END FROM sales','status'],
['SELECT CASE WHEN amount>10 AN¦ FROM sales','AND'],
['SELECT CASE WHEN amount>10 AND st¦ FROM sales','status'],
['SELECT CASE WHEN amount I¦ FROM sales','IS']
])assert.equal(top(marked),expected,marked);
for(const [marked,expected]of [['SELECT CASE WHEN amount> TH¦ FROM sales','threshold'],['SELECT CASE WHEN amount>0 THEN EL¦ FROM sales','elsewhere'],['SELECT CASE WHEN amount>0 THEN 1 ELSE WH¦ FROM sales','when_created']])assert.equal(top(marked),expected,marked);
});

test('CASE expressions work outside SELECT lists and qualified keyword columns stay ordinary names',()=>{
for(const marked of ['SELECT * FROM sales WHERE CASE WHEN amount>0 TH¦ 1 ELSE 0 END=1','SELECT SUM(CASE WHEN amount>0 TH¦ amount ELSE 0 END) FROM sales','SELECT SUM(amount) FROM sales HAVING CASE WHEN SUM(amount)>0 TH¦ 1 ELSE 0 END=1','SELECT * FROM sales ORDER BY CASE WHEN amount>0 TH¦ 1 ELSE 0 END','UPDATE sales SET status=CASE WHEN amount>0 TH¦ \'paid\' ELSE \'pending\' END','SELECT CASE WHEN s.end>0 TH¦ 1 ELSE 0 END FROM sales s','SELECT CASE WHEN s.when>0 TH¦ 1 ELSE 0 END FROM sales s'])assert.equal(top(marked),'THEN',marked);
});

test('completed CASE expressions do not leak into other expressions or nested SELECTs',()=>{
assert.equal(top('SELECT CASE WHEN amount>0 THEN 1 END, TH¦ FROM sales'),'threshold');
assert.equal(top('SELECT CASE WHEN amount>0 THEN 1 END FROM sales ORDER BY am¦'),'amount');
assert.equal(top('SELECT CASE WHEN amount>0 THEN 1 END AS positive_flag FROM sales ORDER BY pos¦'),'positive_flag');
assert.equal(top('SELECT CASE WHEN amount>0 THEN 1 END FROM sales; SELECT TH¦ FROM sales'),'threshold');
assert.equal(top('SELECT CASE WHEN amount>0 THEN (SELECT TH¦ FROM sales) END FROM sales'),'threshold');
assert.equal(top('SELECT CASE WHEN amount>0 THEN (SELECT CASE WHEN amount>0 TH¦ FROM sales) END FROM sales'),'THEN');
for(const marked of ["SELECT CASE WHEN status='TH¦",'SELECT CASE WHEN amount>0 -- TH¦','SELECT CASE WHEN amount>0 /* TH¦'])assert.equal(top(marked),null,marked);
});

test('completed CASE suggestions execute in SQLite with the original text after the cursor',async()=>{
const SQL=await require('sql.js')(),db=new SQL.Database();
try{
db.run("CREATE TABLE sales(amount REAL,status TEXT); INSERT INTO sales VALUES(150,'paid'),(25,'pending'),(0,NULL);");
for(const marked of ['SELECT CASE WHEN amount>100 TH¦ 1 ELSE 0 END FROM sales;','SELECT CASE WHEN amount>100 THEN 1 EL¦ 0 END FROM sales;','SELECT CASE WHEN amount>100 THEN 1 ELSE 0 EN¦ FROM sales;',"SELECT CASE status WH¦ 'paid' THEN 1 ELSE 0 END FROM sales;",'SELECT CASE WHEN amount>0 THEN CASE WHEN amount>100 TH¦ 2 ELSE 1 END ELSE 0 END FROM sales;']){
const completion=suggest(marked);assert(completion,marked);const position=marked.indexOf('¦'),text=marked.replace('¦',''),accepted=text.slice(0,completion.start)+completion.top+text.slice(position);assert.doesNotThrow(()=>db.exec(accepted),accepted);assert.equal(db.exec(accepted)[0].values.length,3);
}
}finally{db.close();}
});
4 changes: 4 additions & 0 deletions scripts/test-sql-completion-ui.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ LEFT JOIN installments AS i ON l.loan_id = i.loan_id`;
const aliased=await accept('SELECT SUM(due_amount) AS total_revenue FROM installments '+clause,'total_revenue',value+': native Tab completes a SELECT output alias in '+clause.split(' ')[0]);
await app.evaluate('window.__pg.run('+JSON.stringify(aliased)+')');check(await app.evaluate('!window.__pg.lastErr()&&window.__pg.resultSets()[0].values[0][0]===120'),value+': accepted output alias executes with the original query suffix');
}
for(const [marked,expected]of [['SELECT CASE WHEN due_amount>100 TH¦ 1 ELSE 0 END AS bucket FROM installments','THEN'],['SELECT CASE WHEN due_amount>100 THEN 1 EL¦ 0 END AS bucket FROM installments','ELSE'],['SELECT CASE WHEN due_amount>100 THEN 1 ELSE 0 EN¦ AS bucket FROM installments','END']]){
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 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