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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ A desktop SQL learning workspace that brings local SQLite, an AI tutor, visual s

<p align="center"><a href="https://github.com/canyrtcn/PractiSQL/releases"><img src="docs/assets/download-windows.en.svg" width="328" height="64" alt="Download PractiSQL for Windows"></a></p>

<p align="center"><strong>Windows x64 · v3.4.269</strong> · <strong>macOS is in preparation.</strong></p>
<p align="center"><strong>Windows x64 · v3.4.270</strong> · <strong>macOS is in preparation.</strong></p>

The button opens Releases, where the Windows x64 installer is available. No GitHub account is required to download it.

Expand Down Expand Up @@ -184,7 +184,7 @@ Choose an available model entry in your account. Model names and access can vary
3. Load a supported database or file, or open a built-in learning example. Start writing and running SQL immediately.
4. Connect AI when you want guided explanations, generated queries or AI-backed learning workflows. You can leave this step for later.

[Release notes](docs/releases/v3.4.269.md) · [Build from source](#development-and-repository-layout)
[Release notes](docs/releases/v3.4.270.md) · [Build from source](#development-and-repository-layout)

## More screens and documentation

Expand Down
4 changes: 2 additions & 2 deletions README.tr.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Yerel SQLite, AI öğretmen, görsel şema ve uygulamalı öğrenmeyi, bu akış

<p align="center"><a href="https://github.com/canyrtcn/PractiSQL/releases"><img src="docs/assets/download-windows.tr.svg" width="328" height="64" alt="Windows için PractiSQL indir"></a></p>

<p align="center"><strong>Windows x64 · v3.4.269</strong> · <strong>macOS hazırlanıyor.</strong></p>
<p align="center"><strong>Windows x64 · v3.4.270</strong> · <strong>macOS hazırlanıyor.</strong></p>

Düğme, Windows x64 kurucusunun bulunduğu Releases sayfasını açar. İndirmek için GitHub hesabı gerekmez.

Expand Down Expand Up @@ -182,7 +182,7 @@ Hesabında kullanılabilen model seçeneğini kullan. Model adları ve erişim d
3. Desteklenen veritabanını veya dosyanı yükle ya da hazır bir öğrenme örneği aç. SQL yazmaya ve çalıştırmaya hemen başlayabilirsin.
4. Açıklama, sorgu üretimi veya AI destekli öğrenme akışları istediğinde AI bağlantısı ekle. Bu adımı sonraya bırakabilirsin.

[Sürüm notları](docs/releases/v3.4.269.md) · [Kaynaktan çalıştır](#geliştirme-ve-repo-düzeni)
[Sürüm notları](docs/releases/v3.4.270.md) · [Kaynaktan çalıştır](#geliştirme-ve-repo-düzeni)

## Daha fazla ekran ve belge

Expand Down
19 changes: 18 additions & 1 deletion app/renderer/sql-completion.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@
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 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 [];
const begin=tokens.findLastIndex(token=>token.start<position&&is(token,'SELECT'));if(begin<0)return [];
const finish=tokens.findIndex((token,index)=>index>begin&&is(token,'FROM','WHERE','GROUP','HAVING','ORDER','LIMIT','WINDOW'));
const projection=tokens.slice(begin+1,finish<0?undefined:finish),groups=[[]];
for(const token of projection){if(token.value===','&&!token.quoted)groups.push([]);else groups.at(-1).push(token);}
const names=[];
for(const group of groups){
const alias=group.at(-1),previous=group.at(-2);if(!alias||!previous||alias.string||!alias.quoted&&(!/^[\p{L}_][\p{L}\p{N}_$]*$/u.test(alias.value)||reserved.has(alias.upper)))continue;
const explicit=is(previous,'AS')&&group.length>2;
const implicit=!is(previous,'AS','COLLATE')&&previous.end<alias.start&&(previous.quoted||previous.value===')'||is(previous,'END')||/^[\p{L}\p{N}_]/u.test(previous.value)&&!reserved.has(previous.upper));
if(explicit||implicit)names.push(alias.value);
}
return names.filter((name,index)=>names.findIndex(other=>other.toLowerCase()===name.toLowerCase())===index&&names.filter(other=>other.toLowerCase()===name.toLowerCase()).length===1).map(spelling);
}
function joinCondition(a,b){
const possibilities=[];
for(const [child,parent]of [[a,b],[b,a]]){
Expand Down Expand Up @@ -110,7 +126,8 @@
const qualifier=last?.value==='.'?tokens.at(-2)?.value:null;
const wildcard=upper==='*'&&(is(tokens.at(-2),'SELECT','DISTINCT')||tokens.at(-2)?.value==='.'||clause==='SELECT'&&!insideExpression&&tokens.at(-2)?.value===',');
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 expression=[...columnNames,...funcs,'CASE','NULL'];
const aliases=!windowScope&&['ORDER','HAVING'].includes(clause)?outputAliases(allSame,position):[];
const expression=[...aliases,...columnNames,...funcs,'CASE','NULL'];
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'));
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture/architecture.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "3.4.269",
"date": "2026-10-21",
"version": "3.4.270",
"date": "2026-10-27",
"defaultLocale": "en",
"views": [
{
Expand Down Expand Up @@ -1159,7 +1159,7 @@
"references": {
".github/SECURITY.md": "470353c7e0447bd45fea3e4040507f03253c3cc762bac12ed424e9f6f93d648d",
"LICENSE": "c03af9304b8b44d8f65921a325a7f2077ebb89e5c64fc309106b3c547ef02d09",
"README.md": "05a90e92c8829fe475fec2b158fb8c3f4a1b110940230caf1266486a49d24200",
"README.md": "74e2b60b292faa39f6624a22429f01d9e234a993e9056ad889ecfb320e20281c",
"app/ai-rate-limit.js": "fdf24873dfb664444f28728abe483a3f363e3aa61d16201a2cb1429ef67a2ff8",
"app/chatgpt-account.js": "1ea42388b92788b93804a9f0b6fc3441039ce26af2b837e9e5e732c160a99f32",
"app/database-inspection/runner.cjs": "32265046ec123e4248845436a523f67ff069bb7ebd8bb83102fc43e249d11d20",
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/index.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/architecture/request-flow.en.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/architecture/request-flow.tr.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions docs/releases/v3.4.270.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### English

- Local SQL completion now recognizes SELECT output aliases in `ORDER BY` and `HAVING`, including quoted names and aggregate expressions. Suggestions stay within the current SELECT scope and preserve SQL after the cursor.

Windows x64: download **Setup.exe** below. **.blockmap** and **latest.yml** support in-app updates; they do not need to be opened manually.

### Türkçe

- Yerel SQL tamamlama, SELECT sonuç takma adlarını artık `ORDER BY` ve `HAVING` içinde tanır. Tırnaklı isimler ve toplama fonksiyonları desteklenir; öneriler mevcut SELECT kapsamını ve imleçten sonraki SQL'i korur.

Windows x64 için aşağıdaki **Setup.exe** kurucusunu indirin. **.blockmap** ve **latest.yml** uygulama içi güncellemeyi destekler; bunları elle açmanız gerekmez.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "practisql",
"version": "3.4.269",
"version": "3.4.270",
"description": "PractiSQL — AI-powered SQL playground.",
"main": "app/main.js",
"build": {
Expand Down 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-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-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
6 changes: 6 additions & 0 deletions scripts/test-sql-completion-ui.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ LEFT JOIN installments AS i ON l.loan_id = i.loan_id`;
for(const value of ['word','next1','next3','line']){
await mode(value);const completed=await accept(query,'DESC',value+': native Tab accepts DESC inside the window without changing the suffix');
await app.evaluate('window.__pg.run('+JSON.stringify(completed)+')');check(await app.evaluate('!window.__pg.lastErr()&&window.__pg.resultSets()[0].values[0][5]===1'),value+': the completed window query executes through the existing SQLite engine');
for(const clause of ['ORDER BY tot¦ DESC','HAVING tot¦ > 0']){
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');
}
}
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');
await mode('next3');await accept(query.replace('D¦','DESC')+'\nLE¦','LEFT JOIN','A new JOIN follows the completed ON predicate instead of a left-prefixed column');
for(const [value,expected]of [['word','LEFT'],['next1','LEFT JOIN'],['next3','LEFT JOIN loans ON'],['line','LEFT JOIN loans ON loans.customer_id = c.customer_id']]){await mode(value);await accept('SELECT * FROM customers c LE¦',expected,value+': native Tab uses the configured continuation budget');}
await mode('next3');await accept('SELECT * FROM customers c LEFT¦','LEFT JOIN loans ON','A fully typed keyword can still extend into a multi-word suggestion');
Expand Down
71 changes: 71 additions & 0 deletions scripts/test-sql-output-aliases.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use strict';
const assert=require('node:assert/strict'),{test}=require('node:test');
const {create}=require('../app/renderer/sql-completion.js');
const schema=[{name:'orders',columns:['order_id','customer_id','amount','total_raw']},{name:'customers',columns:['customer_id','name']}];
function suggest(marked,mode='next3',tables=schema,complete=create()){const position=marked.indexOf('¦');assert(position>=0);return complete({text:marked.replace('¦',''),position,mode,schema:tables});}
const top=(...args)=>suggest(...args)?.top??null;

test('ORDER BY and HAVING complete explicit output aliases in every suggestion mode',()=>{
for(const mode of ['word','next1','next3','line']){
for(const prefix of ['t','tot','total_rev'])assert.equal(top('SELECT SUM(amount) AS total_revenue FROM orders ORDER BY '+prefix+'¦',mode),'total_revenue');
assert.equal(top('SELECT customer_id,SUM(amount) AS total_revenue FROM orders GROUP BY customer_id HAVING tot¦ > 100',mode),'total_revenue');
assert.equal(top('SELECT customer_id,SUM(amount) AS total_revenue FROM orders GROUP BY customer_id HAVING customer_id>0 AND tot¦ > 100',mode),'total_revenue');
assert.equal(top('SELECT SUM(amount) AS total_revenue FROM orders ORDER BY total_revenue D¦',mode),'DESC');
}
});

test('quoted, Unicode, nested expressions and bare result aliases retain valid spelling',()=>{
for(const [projection,prefix,expected] of [
['ROUND(AVG(amount),2) AS average_amount','aver','average_amount'],
['SUM(amount) AS "total revenue"','tot','"total revenue"'],
['SUM(amount) AS `total revenue`','tot','"total revenue"'],
['SUM(amount) AS [total revenue]','tot','"total revenue"'],
['SUM(amount) AS toplam_gelir','top','toplam_gelir'],
['COUNT(*) AS "order"','ord','"order"'],
['SUM(amount) total_revenue','tot','total_revenue'],
['amount * 2 AS doubled_amount','dou','doubled_amount'],
['CASE WHEN amount>0 THEN amount ELSE 0 END AS positive_amount','pos','positive_amount'],
['(SELECT COUNT(*) AS internal_count FROM customers) AS customer_count','cust','customer_count']
])assert.equal(top('SELECT '+projection+' FROM orders ORDER BY '+prefix+'¦'),expected,projection);
assert.equal(top('SELECT SUM(amount) AS total_revenue, COUNT(*) AS order_count FROM orders ORDER BY total_revenue, ord¦'),'order_count');
});

test('aliases stay in their SELECT scope and do not become table-qualified columns',()=>{
for(const sql of [
'SELECT SUM(amount) AS total_revenue FROM orders; SELECT * FROM customers ORDER BY tot¦',
'SELECT (SELECT SUM(amount) AS inner_total FROM orders) AS outer_total FROM customers ORDER BY inner_t¦',
'SELECT (SELECT amount FROM orders ORDER BY outer_t¦) AS outer_total FROM customers',
'WITH totals AS (SELECT SUM(amount) AS cte_total FROM orders) SELECT * FROM customers ORDER BY cte_t¦',
'SELECT amount AS previous_total FROM orders UNION ALL SELECT customer_id AS current_total FROM customers ORDER BY previous_t¦',
'SELECT SUM(amount) AS total_revenue FROM orders o ORDER BY o.total_rev¦',
'SELECT SUM(amount) AS total_revenue, ROW_NUMBER() OVER (ORDER BY total_rev¦) FROM orders',
'SELECT amount + total_rev¦ AS total_revenue FROM orders',
'SELECT SUM(amount) AS total_revenue FROM orders ORDER BY "tot¦',
"SELECT SUM(amount) AS total_revenue FROM orders ORDER BY 'tot¦",
'SELECT SUM(amount) AS total_revenue FROM orders ORDER BY /* tot¦'
])assert.equal(top(sql),null,sql);
assert.equal(top('SELECT * FROM orders WHERE EXISTS (SELECT SUM(amount) AS inner_total FROM orders HAVING inner_t¦ > 0)'),'inner_total');
assert.equal(top('SELECT (SELECT SUM(amount) AS inner_total FROM orders) AS outer_total FROM customers ORDER BY outer_t¦'),'outer_total');
const reusable=create();assert.equal(top('SELECT SUM(amount) AS total_revenue FROM orders ORDER BY tot¦','line',schema,reusable),'total_revenue');assert.equal(top('SELECT * FROM customers ORDER BY tot¦','line',schema,reusable),null);
});

test('duplicate names are excluded and schema columns remain available beside output aliases',()=>{
assert.equal(top('SELECT amount AS total_revenue, customer_id AS total_revenue FROM orders ORDER BY total_rev¦'),null);
assert.equal(top('SELECT SUM(amount) AS total_revenue FROM orders ORDER BY total_r¦'),'total_revenue');
assert.equal(top('SELECT SUM(amount) AS total_revenue FROM orders HAVING total_r¦ > 100'),'total_revenue');
assert.equal(top('SELECT SUM(amount) AS total_revenue FROM orders HAVING total_ra¦ > 100'),'total_raw');
assert.equal(top('SELECT SUM(amount) AS total_revenue FROM orders ORDER BY am¦'),'amount');
assert.equal(top('SELECT amount + 1 FROM orders ORDER BY am¦'),'amount');
assert.equal(top('SELECT CAST(amount AS REAL) FROM orders ORDER BY REA¦'),null);
assert.equal(top('SELECT amount AS total_revenue FROM orders ORDER BY ¦'),null);
});

test('accepted output aliases execute through SQLite without changing the query suffix',async()=>{
const SQL=await require('sql.js')(),db=new SQL.Database();
try{
db.run('CREATE TABLE orders(order_id INTEGER,customer_id INTEGER,amount REAL,total_raw REAL); INSERT INTO orders VALUES(1,1,25,100),(2,1,15,120),(3,2,10,80); CREATE TABLE customers(customer_id INTEGER,name TEXT); INSERT INTO customers VALUES(1,\'A\'),(2,\'B\');');
for(const marked of ['SELECT customer_id,SUM(amount) AS total_revenue FROM orders GROUP BY customer_id ORDER BY tot¦ DESC;','SELECT customer_id,SUM(amount) AS total_revenue FROM orders GROUP BY customer_id HAVING tot¦>20 ORDER BY total_revenue DESC;','SELECT ROUND(AVG(amount),2) AS "average amount" FROM orders HAVING aver¦ > 0;','SELECT amount * 2 doubled_amount FROM orders ORDER BY dou¦ DESC;']){
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(db.exec(accepted)[0].values.length>0,accepted);
}
}finally{db.close();}
});
Loading