From ca2187bae49fb69885dc23c97fbdc0af1deed869 Mon Sep 17 00:00:00 2001 From: canyrtcn <200778076+canyrtcn@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:10:46 +0300 Subject: [PATCH] Complete CASE expression keywords using nested branch context --- app/renderer/sql-completion.js | 24 ++++++++- package.json | 2 +- scripts/test-sql-case-completion.cjs | 73 ++++++++++++++++++++++++++++ scripts/test-sql-completion-ui.cjs | 4 ++ 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 scripts/test-sql-case-completion.cjs diff --git a/app/renderer/sql-completion.js b/app/renderer/sql-completion.js index 3641ddb..0c09486 100644 --- a/app/renderer/sql-completion.js +++ b/app/renderer/sql-completion.js @@ -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;indextoken.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 []; @@ -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']; @@ -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'&¤tJoin)matches=['ON '+currentJoin];else return null;} + if(!typed&&operand&&!qualifier&&!caseLevel){if(clause==='SELECT')matches=!hasFrom?['FROM']:[];else if(clause==='JOIN'&¤tJoin)matches=['ON '+currentJoin];else return null;} let top=matches[0];if(!top)return null; if(mode!=='word'){ const completed=top.toUpperCase(); diff --git a/package.json b/package.json index 578f0db..8ce2243 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/test-sql-case-completion.cjs b/scripts/test-sql-case-completion.cjs new file mode 100644 index 0000000..01c90e9 --- /dev/null +++ b/scripts/test-sql-case-completion.cjs @@ -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();} +}); diff --git a/scripts/test-sql-completion-ui.cjs b/scripts/test-sql-completion-ui.cjs index 169fb5f..fa3de17 100644 --- a/scripts/test-sql-completion-ui.cjs +++ b/scripts/test-sql-completion-ui.cjs @@ -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');