Tecnologias/05 - Comparações5 min
Dialetos SQL na Prática
Conceito
Nota de referência: a mesma operação escrita nos três dialetos. Serve para consulta durante migração, code review e para calibrar a expectativa de portabilidade — que é mais baixa do que parece.
O padrão ANSI SQL cobre o núcleo (SELECT, JOIN, GROUP BY, transação). Tudo o que um
sistema real precisa — paginação, upsert, identidade, data, JSON, agregação de string —
foi implementado antes ou fora do padrão, e por isso divergiu.
Paginação
-- PostgreSQL / MySQL
SELECT * FROM pedidos ORDER BY id LIMIT 20 OFFSET 40;
-- Oracle 12c+ (padrão ANSI, também funciona no Postgres)
SELECT * FROM pedidos ORDER BY id OFFSET 40 ROWS FETCH FIRST 20 ROWS ONLY;
-- Oracle antigo (você vai encontrar isso em código legado)
SELECT * FROM (
SELECT p.*, ROWNUM rn FROM (SELECT * FROM pedidos ORDER BY id) p
WHERE ROWNUM <= 60
) WHERE rn > 40;
OFFSET grande é lento nos trêsOFFSET 100000 faz o banco ler e descartar 100 mil linhas. Para listas longas, use
paginação por cursor: WHERE id > :ultimo_id ORDER BY id LIMIT 20. Vale nos três e
é O(log n) em vez de O(n).
Upsert
-- PostgreSQL
INSERT INTO t (k, v) VALUES ('a', 1)
ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v;
-- MySQL
INSERT INTO t (k, v) VALUES ('a', 1)
ON DUPLICATE KEY UPDATE v = VALUES(v); -- 8.0.20+: v = new.v
-- Oracle
MERGE INTO t USING (SELECT 'a' k, 1 v FROM dual) s ON (t.k = s.k)
WHEN MATCHED THEN UPDATE SET t.v = s.v
WHEN NOT MATCHED THEN INSERT (k, v) VALUES (s.k, s.v);
Identidade / auto-incremento
-- PostgreSQL
id bigserial PRIMARY KEY -- forma histórica
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY -- forma padrão, preferível
-- MySQL
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
-- Oracle 12c+
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY
-- Oracle antigo: CREATE SEQUENCE + trigger BEFORE INSERT
Retorno da linha inserida
| Como | |
|---|---|
| PostgreSQL | INSERT ... RETURNING id, criado_em |
| MySQL | não existe; SELECT LAST_INSERT_ID() (só a PK, só auto-increment) |
| Oracle | INSERT ... RETURNING id INTO :out (PL/SQL ou bind de saída no driver) |
É uma das diferenças mais irritantes na prática: no Postgres você resolve "inserir e devolver o objeto criado" em uma ida ao banco; no MySQL, em duas — com uma janela de concorrência no meio.
Tabela de equivalências
| Operação | PostgreSQL | MySQL | Oracle |
|---|---|---|---|
| Concatenar | a || b |
CONCAT(a,b) (|| é OR!) |
a || b ou CONCAT |
| Agora | now(), CURRENT_TIMESTAMP |
NOW() |
SYSDATE, SYSTIMESTAMP |
| Data sem hora | date |
DATE |
DATE inclui hora (use TRUNC) |
| Booleano | boolean |
TINYINT(1) / BOOL |
NUMBER(1) ou CHAR(1); BOOLEAN em SQL só no 23ai+ |
| Texto ilimitado | text |
TEXT/LONGTEXT |
CLOB (VARCHAR2 até 4000/32767) |
| Aspas em identificador | "tabela" |
`tabela` |
"TABELA" (vira MAIÚSCULA se sem aspas) |
| String vazia | '' ≠ NULL |
'' ≠ NULL |
'' = NULL |
| Agregar strings | string_agg(x, ',') |
GROUP_CONCAT(x) |
LISTAGG(x, ',') |
| Primeiro não-nulo | coalesce |
coalesce/IFNULL |
coalesce/NVL |
| Casting | x::int ou CAST |
CAST(x AS SIGNED) |
CAST / TO_NUMBER |
| Tabela dummy | SELECT 1 |
SELECT 1 |
SELECT 1 FROM dual |
| Somar dias | d + interval '7 day' |
DATE_ADD(d, INTERVAL 7 DAY) |
d + 7 |
| Diferença de datas | a - b (interval) |
DATEDIFF(a,b) |
a - b (em dias, número) |
| JSON: extrair | j->>'campo' |
j->>'$.campo' |
JSON_VALUE(j, '$.campo') |
| Random | random() |
RAND() |
DBMS_RANDOM.VALUE |
| Regex | ~, regexp_replace |
REGEXP_LIKE |
REGEXP_LIKE |
| Fila com skip | FOR UPDATE SKIP LOCKED |
FOR UPDATE SKIP LOCKED (8.0+) |
FOR UPDATE SKIP LOCKED |
Limitar UPDATE |
não suporta LIMIT |
UPDATE ... LIMIT 100 |
não suporta (use ROWNUM na subquery) |
| Ver plano | EXPLAIN (ANALYZE, BUFFERS) |
EXPLAIN ANALYZE (8.0.18+) |
EXPLAIN PLAN + DBMS_XPLAN / AUTOTRACE |
Sensibilidade a maiúsculas — a diferença silenciosa
| Nome de tabela | Comparação de texto (padrão) | |
|---|---|---|
| PostgreSQL | case-sensitive; sem aspas vira minúscula | sensível: 'A' <> 'a' |
| MySQL | depende do sistema de arquivos (Linux sim, macOS/Windows não) | insensível (collation _ci) |
| Oracle | sem aspas vira MAIÚSCULA | sensível (salvo collation nova) |
É a origem de dois bugs clássicos: o código que funciona no Mac do dev e quebra no Linux
da produção (MySQL), e o login por e-mail que aceita Joao@x.com no MySQL e recusa no
Postgres. No Postgres, a solução é índice por expressão — CREATE UNIQUE INDEX ON users (lower(email)) — ou o tipo citext.
Trade-offs
ORM não resolve, adia. Um ORM traduz SELECT/INSERT básico e entrega ao driver as
partes divergentes: tipos (data, boolean, JSON), locks, paginação, upsert e migração. O
teste honesto: rode a suíte contra o banco real, no dialeto real, num contêiner
(Testcontainers). SQLite em memória "porque é mais rápido" é a forma mais eficiente de
descobrir a diferença em produção. Ver 2. Teste de Integração.
SQL portável é SQL pobre. Se a regra do projeto é "tem que rodar em qualquer banco",
você abriu mão de jsonb, índice parcial, RETURNING, tipos de intervalo e
particionamento — e provavelmente reimplementou uma parte disso na aplicação, pior.
O trade-off central: portabilidade de banco é um requisito que raramente se concretiza e que sempre cobra. Só pague por ele se de fato existirem dois bancos no seu contrato — vender software instalado no cliente, por exemplo.
Exemplo prático
A mesma pergunta de negócio — "os 3 maiores pedidos de cada cliente no último mês" — escrita de forma quase idêntica nos três, porque window function é padrão e os três suportam (MySQL desde o 8.0):
WITH ranqueado AS (
SELECT cliente_id, id, total,
ROW_NUMBER() OVER (PARTITION BY cliente_id ORDER BY total DESC) AS rn
FROM pedidos
WHERE criado_em >= /* aqui o dialeto entra */
)
SELECT cliente_id, id, total
FROM ranqueado
WHERE rn <= 3
ORDER BY cliente_id, rn;
Só a data divergiu:
-- PostgreSQL: now() - interval '1 month'
-- MySQL: DATE_SUB(NOW(), INTERVAL 1 MONTH)
-- Oracle: ADD_MONTHS(SYSDATE, -1)
A lição é útil: o SQL analítico moderno (CTE, window function, FILTER, GROUPING SETS) é a parte mais portável do SQL. O que diverge é a borda — data, JSON,
identidade, upsert, lock. Concentre a portabilidade onde ela é barata e aceite dialeto no
resto. Ver 2. Agregações.
Aprofundamento aplicado
Paginação precisa de desempate estável
Ordenar só por data não define a ordem entre linhas com a mesma data. Um cursor deve
conter os valores que determinam a ordenação completa. Exemplo de predicado para
ordem crescente por criado_em, id, usando parâmetros nomeados ilustrativos:
WHERE criado_em > :ultima_data
OR (criado_em = :ultima_data AND id > :ultimo_id)
ORDER BY criado_em, id
A cláusula de limite e a sintaxe de parâmetros devem seguir o banco e o driver. O cursor evita descartar todas as páginas anteriores quando há acesso indexado adequado; ele não fixa automaticamente um snapshot entre requisições HTTP.
Portabilidade inclui semântica
Testes de migração devem incluir NULL, string vazia, precisão decimal, comparação de
texto, timezone e ordenação. Um SQL que compila pode devolver um conjunto diferente
por causa desses detalhes. Converta datas e números de maneira deliberada, sem depender
de formato regional implícito.
Exercício: crie três linhas com o mesmo timestamp e navegue com páginas de duas. Resultado esperado: cada ID aparece uma vez com o desempate, sem depender da ordem física da tabela.
Referência: ordenação de resultados no PostgreSQL.
Relacionado
- 1. PostgreSQL vs MySQL vs Oracle
- 1. PostgreSQL
- 1. MySQL
- 1. Oracle Database
- 2. Agregações
- 3. Subqueries
- SQL Tuning
Parte de MOC Comparações · Comandos SQL (PostgreSQL)