Como o pg_synonym funciona por dentro

Este guia é para quem quer entender, manter ou evoluir a extensão sem ser especialista em C. Quase tudo aqui é SQL e PL/pgSQL. Só uma peça pequena é escrita em C, e ela também está explicada.

1. A ideia

O PostgreSQL não deixa extensões criarem comandos novos, então não dá para ter CREATE SYNONYM. Mas dá para chegar muito perto juntando quatro peças que o PostgreSQL já tem:

Peça Para que serve
Uma tabela de catálogo (synonym.catalog) Guarda os sinônimos. É a “fonte da verdade”.
Uma view por sinônimo É o que as queries usam: SELECT * FROM vendas.cliente.
Event triggers Avisam a extensão quando alguém roda DDL (CREATE, ALTER, DROP).
Uma função em C Enxerga o comando antes de ele rodar, para liberar o caminho.

A view de cada sinônimo é sempre esta:

CREATE VIEW vendas.cliente WITH (security_invoker = true) AS SELECT * FROM erp.clientes;

Por que isso funciona tão bem:

  • Views simples são atualizáveis. Uma view que é só SELECT * FROM uma_tabela aceita INSERT, UPDATE, DELETE e ON CONFLICT, e usa os defaults da tabela. Isso existe no PostgreSQL desde a versão 9.3.
  • O planner elimina a view. Ele reescreve SELECT ... FROM vendas.cliente como SELECT ... FROM erp.clientes, então não há custo nenhum nas queries.
  • security_invoker = true (PostgreSQL 15+) faz o PostgreSQL checar as permissões de quem está usando a view, e não do dono dela. Por isso o sinônimo nunca dá acesso extra a ninguém. É também o motivo de a extensão exigir PostgreSQL 15.

O problema de uma view comum é o ALTER TABLE. Ela “congela” as colunas na hora em que foi criada, e o PostgreSQL recusa DROP COLUMN, ALTER COLUMN TYPE e DROP TABLE enquanto a view existir. O resto da extensão existe para resolver isso.

2. Os arquivos

pg_synonym/
├── pg_synonym.control       ← identidade: versão, schema fixo "synonym", exige superusuário
├── pg_synonym--1.0.sql      ← quase tudo: catálogo, funções, event triggers
├── src/pg_synonym.c         ← só a função "antes do comando"
├── Makefile                 ← compilação com PGXS
└── test/sql, test/expected  ← testes de regressão

O .control

relocatable = false
schema = synonym
superuser = true
  • schema = synonym: tudo da extensão fica no schema synonym, criado automaticamente. Por isso a chamada é synonym.create(...).
  • superuser = true: só superusuário instala, porque criar event triggers exige superusuário. Depois de instalada, qualquer usuário pode criar sinônimos.

3. O catálogo e a segurança por linha (RLS)

CREATE TABLE synonym.catalog (
    syn_schema, syn_name,     -- onde fica o sinônimo
    tgt_schema, tgt_name,     -- para onde ele aponta
    owner, created_at
);

Qualquer pessoa pode ler essa tabela. Para escrever nela existem três políticas de RLS (row-level security):

  • INSERT só é permitido se owner = current_user e se a pessoa tem CREATE no schema do sinônimo. Ou seja, ninguém registra sinônimo em nome de outro, nem em schema onde não poderia criar uma view.
  • DELETE só é permitido para o dono, ou para quem é membro do papel dono.
  • UPDATE não tem política, então ninguém pode alterar linhas. Só as funções internas da extensão, que rodam como superusuário, fazem isso.

pg_extension_config_dump faz o pg_dump incluir as linhas do catálogo no backup.

4. Os estados de um sinônimo: synonym._states()

Esta função é o “cérebro” da extensão. Em uma única consulta, ela olha todos os sinônimos e diz em que estado cada um está:

Estado Situação O que a sincronização faz
ok View existe e bate com o destino nada
pending Destino não existe nada (espera)
missing_view Destino existe, view não cria a view
columns_changed Colunas do destino mudaram ajusta a view
target_renamed Destino foi renomeado ou mudou de schema atualiza o catálogo
conflict Outro objeto ocupa o nome do sinônimo avisa (WARNING)
schema_missing O schema do sinônimo foi apagado remove o sinônimo do catálogo

Dois detalhes importantes:

  • Como saber para qual tabela a view aponta, mesmo depois de um RENAME? Pelas dependências: o PostgreSQL registra em pg_depend que a regra (pg_rewrite) da view depende das colunas da tabela. A consulta segue esse caminho, que acompanha a tabela mesmo que ela troque de nome.
  • Como saber se as colunas mudaram? Comparando dois arrays: nomes e tipos das colunas da view, contra nomes e tipos das colunas do destino, na ordem.

Ser uma consulta só, em vez de um laço linha a linha, fez o custo por DDL cair de ~100 ms para ~11 ms com 500 sinônimos.

5. A sincronização: synonym._sync_one() e synonym._sync_all()

_sync_one pega um sinônimo, pergunta o estado e age. Ela repete isso (até 5 vezes) até chegar a ok ou pending, porque uma ação pode levar a outra: por exemplo, primeiro atualizar o nome do destino, depois ajustar as colunas.

Para ajustar colunas há três caminhos, do mais suave ao mais drástico:

  1. Só colunas novas no fim → CREATE OR REPLACE VIEW. Views que outras pessoas criaram em cima do sinônimo continuam intactas.
  2. Só nomes trocados → ALTER VIEW ... RENAME COLUMN. Para o caso de duas colunas trocarem de nome entre si, isso é feito em duas passadas, com nomes temporários no meio, para não colidir.
  3. Qualquer outra coisa → apaga e recria a view.

_sync_all chama _sync_one para cada sinônimo fora de ok/pending. Se um sinônimo der erro, ela transforma o erro em WARNING, porque um sinônimo quebrado nunca deve fazer falhar o ALTER TABLE de outra pessoa em outra tabela.

Por que SECURITY DEFINER?

Imagine: a Ana é dona de erp.clientes, e o Beto criou o sinônimo app.cliente. A view app.cliente é do Beto. Quando a Ana roda ALTER TABLE erp.clientes ADD COLUMN ..., o sinônimo do Beto precisa ser atualizado, mas a Ana não tem permissão na view do Beto.

Por isso as funções de sincronização são SECURITY DEFINER: rodam com os privilégios de quem instalou a extensão (um superusuário). Para isso ser seguro:

  • elas fixam SET search_path = pg_catalog, pg_temp, para ninguém conseguir “sequestrar” um nome de função ou operador;
  • todos os nomes são montados com format('%I.%I', ...), que coloca aspas corretamente e impede SQL injection;
  • antes de criar uma view, checam se o dono ainda existe e ainda tem CREATE no schema;
  • a view criada é entregue ao dono certo com ALTER VIEW ... OWNER TO.

6. Os três event triggers

Event triggers são gatilhos que disparam em comandos DDL, e não em linhas de tabela.

Depois do comando: synonym._on_ddl_end() (PL/pgSQL)

Dispara no fim de CREATE, ALTER e DROP de tabelas, views e schemas, e chama _sync_all(). Tem dois atalhos:

  • se a variável de sessão pg_synonym.syncing estiver on, sai na hora. As funções da extensão ligam essa variável enquanto rodam (com SET pg_synonym.syncing = on na definição), para que os CREATE VIEW e DROP VIEW delas não disparem o gatilho de novo em loop;
  • se o catálogo estiver vazio, sai na hora. Bancos sem sinônimos quase não pagam nada.

Antes do comando: synonym._on_ddl_start() ©

O PostgreSQL recusa estes comandos enquanto existir uma view dependendo da tabela:

DROP TABLE erp.clientes;
ALTER TABLE erp.clientes DROP COLUMN x;
ALTER TABLE erp.clientes ALTER COLUMN x TYPE bigint;

A solução é apagar as views dos sinônimos envolvidos antes do comando rodar. Depois, o gatilho do fim recria o que fizer sentido. O problema é que um event trigger em PL/pgSQL, no início do comando, não sabe sobre qual tabela o comando é. Um event trigger em C sabe, porque recebe a árvore do comando (parse tree) já interpretada.

O que o C faz, passo a passo (src/pg_synonym.c):

  1. collect_targets() olha a árvore:
    • se é DropStmt de tabela, view, materialized view ou foreign table, pega cada objeto;
    • se é AlterTableStmt com algum DROP COLUMN ou ALTER COLUMN TYPE, pega a tabela.

    Os nomes viram OIDs com RangeVarGetRelid(..., missing_ok = true). Também anota se o usuário pediu CASCADE.

  2. call_detach() chama a função PL/pgSQL synonym._detach(oids, cascade), que apaga as views dos sinônimos que apontam para essas tabelas.

Por segurança, _detach tem EXECUTE revogado de PUBLIC: ninguém consegue chamá-la por SQL. O C a chama diretamente pelo function manager do PostgreSQL (OidFunctionCall2), que não checa essa permissão. Antes, o C confere que a função realmente pertence à extensão (getExtensionOfObject).

E se o usuário não tiver permissão para apagar a tabela? O DROP TABLE falha, a transação inteira é desfeita, e junto com ela as views que o C apagou. Nada se perde.

Quando a view é apagada direto: synonym._on_sql_drop() (PL/pgSQL)

Se alguém roda DROP VIEW vendas.cliente, ou DROP OWNED BY o dono do sinônimo, a intenção é apagar o sinônimo. Sem este gatilho, o gatilho do fim recriaria a view logo em seguida, o que seria uma surpresa.

O evento sql_drop entrega a lista do que foi apagado (pg_event_trigger_dropped_objects()), com uma coluna original: true quando o objeto foi citado no comando, false quando caiu em cascata. Só as views de sinônimo com original = true saem do catálogo. Assim, quando a view cai porque o schema do destino foi apagado com CASCADE, o sinônimo continua e fica pending, esperando o destino voltar.

Esta função roda como quem apagou a view. Quem apaga uma view é o dono dela (ou um superusuário), então a política de DELETE do catálogo deixa remover a linha.

7. As funções públicas

  • synonym.create(nome, destino) roda com as permissões de quem chama (não é SECURITY DEFINER). Ela interpreta os nomes com parse_ident(), que entende aspas e pontos, valida tudo com mensagens claras, insere no catálogo (o RLS confere de novo) e chama _sync_one para criar a view.
  • synonym.drop(nome) também roda como quem chama: confere se a pessoa é dona, apaga a view (como dona) e depois a linha do catálogo.
  • synonym.refresh() força uma sincronização geral. Normalmente não é necessária.
  • synonym.list é uma view que junta o catálogo com _states().

8. Testes

São cinco arquivos em test/sql/, cada um com a saída esperada em test/expected/:

Arquivo O que cobre
basic criar, listar, apagar, nomes com aspas, search_path, mensagens de erro, DROP VIEW e DROP OWNED
dml INSERT/UPDATE/DELETE, ON CONFLICT, constraints, joins, EXPLAIN, views e particionadas como destino
target_changes ADD/RENAME/ALTER TYPE/DROP COLUMN, troca de nomes, RENAME e SET SCHEMA do destino, views dependentes, ROLLBACK
late_binding sinônimo antes do destino, DROP e recriação do destino, schema que some e volta, conflito de nome
security quatro usuários diferentes: quem cria, quem lê, quem não pode nada, RLS no destino, tentativas de burlar o catálogo

Rode com make installcheck. Para adicionar um teste: escreva o SQL, rode, confira a saída em test/results linha por linha e só então copie para test/expected.

9. Ideias para a versão 1.1

  • Sinônimos de funções e procedures. Criar uma função “casca” com a mesma assinatura que chama a original. O desafio são sobrecargas, parâmetros com default, VARIADIC e OUT.
  • Sinônimos de sequences, para nextval('sinonimo').
  • Acompanhar ALTER SCHEMA ... RENAME do schema do sinônimo. Hoje o sinônimo vira uma view comum.
  • Desempenho com milhares de sinônimos: usar pg_event_trigger_ddl_commands() para olhar só os sinônimos afetados pelo comando, em vez de todos.

Para lançar a 1.1, crie pg_synonym--1.0--1.1.sql (atualiza quem já tem a 1.0) e pg_synonym--1.1.sql (instalação do zero), mude default_version no .control e acrescente os dois em DATA no Makefile.