From a5e3cfffe0f291b5abbae8d9760c8c761b4825de Mon Sep 17 00:00:00 2001 From: canyrtcn <200778076+canyrtcn@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:33:42 +0300 Subject: [PATCH] Require the BETWEEN separator before suggesting CASE transitions --- app/renderer/sql-completion.js | 21 ++++++++++++-- package.json | 2 +- scripts/test-sql-between-completion.cjs | 38 +++++++++++++++++++++++++ scripts/test-sql-completion-ui.cjs | 2 ++ 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 scripts/test-sql-between-completion.cjs diff --git a/app/renderer/sql-completion.js b/app/renderer/sql-completion.js index 0c09486..ccaa563 100644 --- a/app/renderer/sql-completion.js +++ b/app/renderer/sql-completion.js @@ -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=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;indexis(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']; @@ -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'&¤tJoin)matches=['ON '+currentJoin];else return null;} + if(!typed&&operand&&!qualifier&&!caseLevel&&!needsBetweenAnd){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 8ce2243..fc5c2ac 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-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", diff --git a/scripts/test-sql-between-completion.cjs b/scripts/test-sql-between-completion.cjs new file mode 100644 index 0000000..5fe3240 --- /dev/null +++ b/scripts/test-sql-between-completion.cjs @@ -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();} +}); diff --git a/scripts/test-sql-completion-ui.cjs b/scripts/test-sql-completion-ui.cjs index fa3de17..8b67f53 100644 --- a/scripts/test-sql-completion-ui.cjs +++ b/scripts/test-sql-completion-ui.cjs @@ -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');