SQL River Style
An opinionated VS Code formatter for hand-written SQL — report queries,
one-off scripts, ad-hoc exports — that lays keywords out in the classic
"river style": SELECT/FROM/WHERE/... right-aligned to a common
column, one column per line, structured CASE blocks and CTEs. It is
not a generic, configurable formatter: it reproduces a fixed set of
house conventions, the same ones this project started from when it was
an internal tool before going public.
Leia isto em português mais abaixo.
Rules applied
- Right-aligned keywords ("river style"):
SELECT, FROM,
WHERE, AND/OR, INNER/LEFT/RIGHT/FULL/CROSS JOIN, ON/USING,
UNION ALL, GROUP BY, ORDER BY, HAVING, LIMIT, OFFSET — and,
in DML, INSERT INTO, UPDATE, SET, DELETE, VALUES,
RETURNING — all end at the same column, set by the longest keyword
in use in that query/statement. When the block has a SELECT, it
never gets less than 4 spaces of indentation before it — even if
SELECT alone would already be the longest keyword in use (e.g. a
SELECT without JOIN whose longest clause is GROUP BY, which
alone would only need 2 spaces).
- One column per line in
SELECT, GROUP BY, ORDER BY — and
also in SET (one assignment per line), VALUES (one tuple per
line) and RETURNING —, vertically aligned under the first item of
the list.
- Table alias: the formatter doesn't add or remove aliases — it
only reformats what's already written. Avoiding aliases (except in
self-joins) is a convention left to whoever writes the query.
- Uppercase: SQL keywords, recognized native functions (
COUNT,
UNNEST, ARRAY_LENGTH, COALESCE...) and casts (::INT,
::TEXT). Lowercase: column-alias as. CTE alias uses uppercase
AS (the one exception).
JOIN condition wrapped in parentheses, on its own line after ON:
ON ( a.x = b.y ). With more than one condition, each AND/OR
breaks onto its own line inside the parentheses, aligned under the
first condition — this holds whether the source already writes
ON ( a.x = b.x AND a.y = b.y ) or without parentheses
(ON a.x = b.x AND a.y = b.y); both format the same way. USING
doesn't get this wrap — it serves both the USING (col1, col2) form
of JOIN and the USING outra_tabela form of Postgres's multi-table
DELETE, so it's left as-is.
CASE/WHEN/THEN/ELSE/END in blocks: the first WHEN stays
on the same line as CASE, each following WHEN/THEN/ELSE
becomes its own line aligned right after CASE (same column as the
first WHEN), and END closes aligned with CASE itself. Applies
both to a SELECT item and to a WHERE/ON/etc. condition.
- Blank line separating
UNION ALL/UNION/EXCEPT/INTERSECT from
what comes before/after, and separating a WITH block's CTEs from
the statement that consumes them (the SELECT/INSERT/UPDATE/
DELETE right after the closing )).
- Chained CTEs with no extra indentation and no blank line between
them: closing one and opening the next on the same line
(
), next_cte AS ().
- Standalone
-- comments (alone on their line) always sit at column
1, unindented — even inside CTEs/subqueries. Comments at the end of a
code line stay at the end of that line.
- No trailing
; at the end of the file (the query is treated as a
fragment). In files with multiple statements, the ; between them
is kept — only the last one is removed.
Example:
SELECT ecm_conteudo.id,
ecm_conteudo.nome,
COUNT(ecm_assinatura.id) as total_assinaturas
FROM ecm_conteudo
INNER JOIN ecm_assinatura
ON ( ecm_assinatura.ecm_conteudo_id = ecm_conteudo.id )
WHERE ecm_conteudo.categoria_id = 10
AND ecm_conteudo.ativo = TRUE
GROUP BY ecm_conteudo.id,
ecm_conteudo.nome
ORDER BY ecm_conteudo.nome
ON with more than one condition breaks one per line (works the same
whether AND/OR is already wrapped in parentheses in the source or
not):
SELECT issues.id
FROM issues
INNER JOIN custom_values campo_nome
ON ( campo_nome.customized_id = issues.id
AND campo_nome.custom_field_id = 109 )
CASE/WHEN/THEN in blocks, one per line (works both as a SELECT
item and inside a WHERE condition):
WHERE issues.status_id <> 42
AND CASE WHEN '${situacao}' = ''
THEN TRUE
WHEN '${situacao}' = 'a' -- Arquivados
THEN issues.status_id = 41
END
Chained CTEs:
WITH primeira_cte AS (
SELECT a.id,
a.valor::NUMERIC
FROM tabela_a a
), segunda_cte AS (
SELECT b.id
FROM tabela_b b
)
SELECT primeira_cte.id
FROM primeira_cte
(The final SELECT gets 4 leading spaces even without a JOIN around —
that's rule 1's minimum-4-spaces-before-SELECT kicking in, since
FROM alone would only ask for 2.)
Subqueries in FROM/JOIN are formatted recursively, with their own
indentation and their own river alignment (independent of the outer
scope) — the same goes for each CTE's body.
UPDATE/DELETE/INSERT follow the same river style (UPDATE/
DELETE share the same clause machinery as SELECT, so FROM,
JOIN, WHERE/AND/OR etc. all work the same way):
UPDATE ecm_conteudo
SET nome = 'novo nome',
ativo = TRUE
WHERE ecm_conteudo.id = 1
AND ecm_conteudo.categoria_id = 2
DELETE
FROM ecm_conteudo
USING ecm_categoria
WHERE ecm_conteudo.categoria_id = ecm_categoria.id
AND ecm_categoria.ativo = FALSE
RETURNING ecm_conteudo.id
INSERT INTO shares the same river with whichever of
VALUES/SELECT/RETURNING comes after it in the same statement:
INSERT INTO ecm_conteudo (nome, categoria_id)
VALUES ('a', 1),
('b', 2)
RETURNING id
Usage
- Format document:
Shift+Alt+F (VS Code's default formatter
shortcut for .sql), or Command Palette → SQL River Style: Format Document.
- Format on save: enable
"editor.formatOnSave": true in VS Code
for .sql files (globally, or under [sql] in settings.json).
Configuration
| Setting |
Default |
Description |
sqlRiverStyle.indentSize |
4 |
Spaces used to indent CTE bodies and derived-table subqueries in FROM/JOIN. |
sqlRiverStyle.additionalFunctions |
[] |
Extra function names (besides the built-in native list) to uppercase when used as a function call, e.g. ["fn_calcula_total"]. |
Scope and known limitations
- Covers
SELECT/WITH (the common case of report queries) and basic
DML — INSERT/UPDATE/DELETE, including UPDATE ... FROM,
DELETE ... USING, INSERT ... VALUES/INSERT ... SELECT and
RETURNING. Other statements (DDL, MERGE, session commands...) are
out of scope: they only get their keywords uppercased, without river
restructuring.
- Function/procedure definitions are not supported at all —
CREATE FUNCTION/CREATE PROCEDURE bodies wrapped in dollar-quoting
(AS $$ ... $$, AS $tag$ ... $tag$) aren't recognized by the
tokenizer, which doesn't know $$ as a string delimiter. Instead of
degrading gracefully like other DDL, this actively mangles the
output — the $$ gets split into stray tokens, whitespace inside the
body collapses, and the file's trailing ; may be stripped even
though it's semantically required there. Don't run this formatter on
files containing function/procedure definitions for now.
INSERT ... ON CONFLICT isn't specifically modeled — ON CONFLICT (...) DO NOTHING stays concatenated at the end of the VALUES line;
in ON CONFLICT (...) DO UPDATE SET ... the SET gets its own line
(reusing the normal SET marker), but the ON CONFLICT (...) DO UPDATE that precedes it stays entirely on the VALUES line. It
doesn't break the query, it just isn't formatted into full river
clauses.
- Uppercased "native functions" come from a fixed list of PostgreSQL
functions (
src/formatter.ts, NATIVE_FUNCTIONS constant); your own
database business functions are only uppercased if listed in
sqlRiverStyle.additionalFunctions.
- Nested
CASE (a CASE inside another's WHEN/THEN/ELSE) only
breaks into blocks at the outermost CASE — the nested one(s) render
inline via renderTokensInline, like any other ordinary expression.
- Chained quoted identifiers (
"tabela"."coluna", common in SQL
exported by query builders such as Laravel's) are recognized as a
single qualified identifier, preserving the . — same as
tabela.coluna unquoted. Any character not recognized by any
tokenizer rule (e.g. a ? bind parameter from a query log) becomes
an isolated token instead of being dropped — the formatter should
never silently erase content from the original SQL, even when it
doesn't know how to format it with ideal spacing.
- Unnecessary quotes around an identifier are stripped:
"tabela"."coluna" becomes tabela.coluna when the content is only
lowercase/digit/_ and doesn't collide with a reserved PostgreSQL
keyword. The collision check uses RESERVED_KEYWORDS
(src/tokenizer.ts) — the full list of "reserved" variants from the
official Postgres keyword
table,
not the curated KEYWORD_SET used for uppercasing/clause markers
(that one is deliberately much smaller — uppercasing CREATE/
TABLE/ARRAY/etc. whenever they appear would collide with DDL,
which is out of scope for this formatter). Non-reserved Postgres
words that are also common column names (date, time, type,
value, text, name...) never lose their quotes by mistake nor
get forced uppercase by this list, since they aren't truly reserved.
- Subqueries used inside an expression (
WHERE x IN (SELECT ...),
SELECT (SELECT ...) AS foo) render on a single line — only
subqueries in FROM/JOIN position (derived tables) get recursive
multi-line formatting.
- Casts whose type name is more than one word (
::double precision,
::character varying, ::timestamp with time zone...) are
uppercased as a whole — the list of recognized phrases is the
MULTI_WORD_CAST_TYPES constant in src/formatter.ts. A compound
name not on that list only gets its first word uppercased.
Development
npm install
npm run compile # or: npm run watch
npm test # snapshot suite in test/run.ts
npm run smoke # prints the formatting of several examples, for manual inspection (test/smoke.ts)
To debug inside VS Code: open this repository's root as a workspace and
press F5 (Extension Development Host).
To build an installable package:
npx @vscode/vsce package
That produces a .vsix file, installable via
code --install-extension sql-river-style-1.0.0.vsix or through VS
Code's Extensions tab → Install from VSIX....
License
MIT — © Eduardo Braun.
Português
Extensão de VS Code que formata SQL escrito à mão — queries de
relatório, scripts avulsos, exportações pontuais — no estilo clássico
"river": SELECT/FROM/WHERE/... alinhados à direita numa coluna
comum, uma coluna por linha, blocos CASE e CTEs estruturados. Não
é um formatter genérico/configurável: reproduz um conjunto fixo de
convenções de estilo, as mesmas com que este projeto começou quando
ainda era uma ferramenta interna, antes de se tornar público.
Read this in English above.
Regras aplicadas
- Keywords alinhadas à direita ("river style"):
SELECT, FROM,
WHERE, AND/OR, INNER/LEFT/RIGHT/FULL/CROSS JOIN, ON/USING,
UNION ALL, GROUP BY, ORDER BY, HAVING, LIMIT, OFFSET — e, em
DML, INSERT INTO, UPDATE, SET, DELETE, VALUES, RETURNING —
terminam todas na mesma coluna, definida pela keyword mais longa em uso
naquela query/statement. Quando o bloco tem um SELECT, ele nunca fica
com menos de 4 espaços de indentação antes dele — mesmo que SELECT
por si só já fosse a keyword mais longa em uso (ex.: um SELECT sem
JOIN cuja cláusula mais longa é GROUP BY, que sozinha só pediria 2
espaços).
- Uma coluna por linha em
SELECT, GROUP BY, ORDER BY — e também
em SET (uma atribuição por linha), VALUES (uma tupla por linha) e
RETURNING —, alinhadas verticalmente sob o primeiro item da lista.
- Alias de tabela: o formatter não adiciona nem remove alias — só
reformata o que está escrito. A convenção de evitar alias (exceto em
self-join) é responsabilidade de quem escreve a query.
- Maiúsculo: palavras-chave SQL, funções nativas reconhecidas
(
COUNT, UNNEST, ARRAY_LENGTH, COALESCE...) e casts (::INT,
::TEXT). Minúsculo: as de alias de coluna. Alias de CTE usa AS
maiúsculo (única exceção).
- Condição de
JOIN entre parênteses, em linha própria após ON:
ON ( a.x = b.y ). Com mais de uma condição, cada AND/OR quebra
linha dentro dos parênteses, alinhado sob a primeira condição — vale
tanto pra quem já escreve ON ( a.x = b.x AND a.y = b.y ) quanto pra
quem escreve sem parênteses (ON a.x = b.x AND a.y = b.y), os dois
formatam igual. USING não ganha esse wrap — serve tanto a forma
USING (col1, col2) de JOIN quanto a forma USING outra_tabela de
DELETE multi-tabela do Postgres, e por isso fica como está.
CASE/WHEN/THEN/ELSE/END em blocos: o primeiro WHEN fica na
mesma linha do CASE, cada WHEN/THEN/ELSE seguinte vira sua
própria linha alinhada logo depois de CASE (mesma coluna do
primeiro WHEN), e END fecha alinhado com o próprio CASE. Vale
tanto num item de SELECT quanto numa condição de WHERE/ON/etc.
- Linha em branco separando
UNION ALL/UNION/EXCEPT/INTERSECT do
que vem antes/depois, e separando o bloco de CTEs de um WITH do
statement que as consome (o SELECT/INSERT/UPDATE/DELETE logo
após o fechamento )).
- CTEs encadeadas sem indentação e sem linha em branco entre elas:
fechamento de uma e abertura da próxima na mesma linha
(
), proxima_cte AS ().
- Comentários
-- standalone (sozinhos na linha) sempre na coluna 1, sem
indentação — mesmo dentro de CTEs/subqueries. Comentários no fim de uma
linha de código permanecem no fim dessa linha.
- Sem
; no final do arquivo (a query é tratada como fragmento). Em
arquivos com múltiplos statements, o ; entre eles é mantido — só o
último é removido.
Exemplo:
SELECT ecm_conteudo.id,
ecm_conteudo.nome,
COUNT(ecm_assinatura.id) as total_assinaturas
FROM ecm_conteudo
INNER JOIN ecm_assinatura
ON ( ecm_assinatura.ecm_conteudo_id = ecm_conteudo.id )
WHERE ecm_conteudo.categoria_id = 10
AND ecm_conteudo.ativo = TRUE
GROUP BY ecm_conteudo.id,
ecm_conteudo.nome
ORDER BY ecm_conteudo.nome
ON com mais de uma condição quebra uma por linha (funciona igual se o
AND/OR já vier entre parênteses no fonte ou não):
SELECT issues.id
FROM issues
INNER JOIN custom_values campo_nome
ON ( campo_nome.customized_id = issues.id
AND campo_nome.custom_field_id = 109 )
CASE/WHEN/THEN em blocos, um por linha (funciona tanto num item de
SELECT quanto dentro de uma condição de WHERE):
WHERE issues.status_id <> 42
AND CASE WHEN '${situacao}' = ''
THEN TRUE
WHEN '${situacao}' = 'a' -- Arquivados
THEN issues.status_id = 41
END
CTEs encadeadas:
WITH primeira_cte AS (
SELECT a.id,
a.valor::NUMERIC
FROM tabela_a a
), segunda_cte AS (
SELECT b.id
FROM tabela_b b
)
SELECT primeira_cte.id
FROM primeira_cte
(O SELECT final ganha 4 espaços à esquerda mesmo sem JOIN por perto —
é a regra 1, o mínimo de 4 espaços antes do SELECT, entrando em ação,
já que sozinho o FROM só pediria 2.)
Subqueries em FROM/JOIN são formatadas recursivamente, com indentação
própria e seu próprio alinhamento de river (independente do escopo
externo) — o mesmo vale para o corpo de cada CTE.
UPDATE/DELETE/INSERT seguem o mesmo river style (UPDATE/DELETE
compartilham a máquina de cláusulas do SELECT, então FROM, JOIN,
WHERE/AND/OR etc. funcionam igual):
UPDATE ecm_conteudo
SET nome = 'novo nome',
ativo = TRUE
WHERE ecm_conteudo.id = 1
AND ecm_conteudo.categoria_id = 2
DELETE
FROM ecm_conteudo
USING ecm_categoria
WHERE ecm_conteudo.categoria_id = ecm_categoria.id
AND ecm_categoria.ativo = FALSE
RETURNING ecm_conteudo.id
INSERT INTO compartilha o mesmo river com VALUES/SELECT/RETURNING
que vierem depois dele na mesma statement:
INSERT INTO ecm_conteudo (nome, categoria_id)
VALUES ('a', 1),
('b', 2)
RETURNING id
Uso
- Formatar documento:
Shift+Alt+F (formatador padrão do VS Code
para .sql), ou paleta de comandos → SQL River Style: Format Document (o título do comando é em inglês — a extensão ainda não tem
tradução da paleta de comandos).
- Format on save: ative
"editor.formatOnSave": true no VS Code para
arquivos .sql (globalmente ou em [sql] no settings.json).
Configuração
| Setting |
Padrão |
Descrição |
sqlRiverStyle.indentSize |
4 |
Espaços usados para indentar corpo de CTEs e subqueries em FROM/JOIN. |
sqlRiverStyle.additionalFunctions |
[] |
Nomes de função extras (além da lista nativa padrão) a maiusculizar quando usados como chamada de função, ex. ["fn_calcula_total"]. |
Escopo e limitações conhecidas
- Cobre
SELECT/WITH (o caso comum de queries de relatório) e o DML
básico — INSERT/UPDATE/DELETE, incluindo UPDATE ... FROM,
DELETE ... USING, INSERT ... VALUES/INSERT ... SELECT e
RETURNING. Outros statements (DDL, MERGE, comandos de sessão...)
ficam fora do escopo: só têm as palavras-chave maiusculizadas, sem
reestruturação em river style.
- Definição de função/procedure não é suportada de jeito nenhum —
corpos de
CREATE FUNCTION/CREATE PROCEDURE entre dollar-quoting
(AS $$ ... $$, AS $tag$ ... $tag$) não são reconhecidos pelo
tokenizer, que não sabe que $$ é um delimitador de string. Em vez
de degradar bem como o resto do DDL, isso bagunça a saída de verdade —
o $$ é quebrado em tokens soltos, espaços dentro do corpo colapsam,
e o ; final do arquivo pode ser removido mesmo sendo
semanticamente obrigatório ali. Por enquanto, não rode o formatter em
arquivos com definição de função/procedure.
INSERT ... ON CONFLICT não é modelado especificamente — ON CONFLICT (...) DO NOTHING fica concatenado no fim da linha de VALUES; em
ON CONFLICT (...) DO UPDATE SET ... o SET ganha sua própria linha
(reaproveitando o marker normal de SET), mas o ON CONFLICT (...) DO UPDATE que vem antes dele fica todo na linha de VALUES. Não quebra a
query, só não é formatado em cláusulas river completas.
- "Funções nativas" maiusculizadas vêm de uma lista fixa de funções do
PostgreSQL (
src/formatter.ts, constante NATIVE_FUNCTIONS); funções
de negócio do seu banco só são maiusculizadas se listadas em
sqlRiverStyle.additionalFunctions.
CASE aninhado (um CASE dentro do WHEN/THEN/ELSE de outro) só
quebra em blocos no CASE mais externo — o(s) aninhado(s) renderizam
inline via renderTokensInline, igual a qualquer expressão comum.
- Identificadores entre aspas encadeados (
"tabela"."coluna", comum em
SQL exportado por query builders como o do Laravel) são reconhecidos
como um único identificador qualificado, preservando o . — assim como
tabela.coluna sem aspas. Qualquer caractere não reconhecido por
nenhuma regra do tokenizer (ex.: ? de bind parameter de log de query)
vira um token isolado em vez de ser descartado — o formatter nunca deve
apagar conteúdo do SQL original em silêncio, mesmo que não saiba
formatá-lo com o espaçamento ideal.
- Aspas desnecessárias em identificador são removidas:
"tabela"."coluna"
vira tabela.coluna quando o conteúdo é só minúsculas/dígito/_ e não
colide com uma palavra reservada do PostgreSQL. A checagem de colisão
usa RESERVED_KEYWORDS (src/tokenizer.ts) — a lista completa de
variantes "reserved" da tabela oficial de keywords do
Postgres,
não a KEYWORD_SET curada usada pra maiúsculas/marcador de cláusula
(essa é bem menor de propósito — colocar CREATE/TABLE/ARRAY/etc.
em maiúsculo sempre que aparecem bateria com DDL, fora do escopo deste
formatter). Palavras não-reservadas do Postgres que também são nomes de
coluna comuns (date, time, type, value, text, name...) nunca
perdem as aspas por engano nem ficam forçadas a maiúsculo por essa
lista, já que não são reservadas de verdade.
- Subqueries usadas dentro de uma expressão (
WHERE x IN (SELECT ...),
SELECT (SELECT ...) AS foo) são renderizadas em uma linha só — apenas
subqueries na posição de FROM/JOIN (derived table) recebem
formatação multi-linha recursiva.
- Casts com nome de tipo composto por mais de uma palavra (
::double precision, ::character varying, ::timestamp with time zone...) são
maiusculizados por inteiro — a lista de frases reconhecidas é a
constante MULTI_WORD_CAST_TYPES em src/formatter.ts. Um nome
composto que não estiver nessa lista maiusculiza só a primeira palavra.
Desenvolvimento
npm install
npm run compile # ou: npm run watch
npm test # suíte de snapshots em test/run.ts
npm run smoke # imprime a formatação de vários exemplos, para inspeção manual (test/smoke.ts)
Para depurar dentro do VS Code: abra a raiz deste repositório como
workspace e pressione F5 (Extension Development Host).
Para gerar um pacote instalável:
npx @vscode/vsce package
Isso gera um .vsix que pode ser instalado via
code --install-extension sql-river-style-1.0.0.vsix ou pela aba de
Extensões do VS Code → Install from VSIX....
Licença
MIT — © Eduardo Braun.
| |