MySQL: MySqlConnector, o Provider do EF e as Diferenças Que Importam

MySQL: MySqlConnector, o Provider do EF e as Diferenças Que Importam

A Livraria no MySQL com MySqlConnector e EF Core, e as diferenças que fazem um sistema falhar em silêncio: agrupamentos que ignoram acentos, o utf8 de 3 bytes, modos de SQL permissivos, MyISAM, o TIMESTAMP que acaba em 2038, a falta de arrays e o upsert de cada banco.
Linguagem C#

• • 18 min de leitura

O mesmo SQL, os mesmos conceitos, e diferenças de comportamento suficientes para um sistema funcionar num banco e falhar em silêncio no outro.

Em "PostgreSQL: o Banco de Dados Servidor, com Npgsql, JSONB e Arrays" a Livraria ganhou um banco servidor com tipos ricos e comparações de texto rigorosas. Mas, se você trabalhar com sistemas existentes, é muito provável que encontre o MySQL: ele foi durante duas décadas o banco de dados de código aberto mais usado do mundo, a base da web dos anos 2000 e o banco padrão de praticamente toda hospedagem compartilhada com painel de controle. Nesta aula conectamos a Livraria ao MySQL, pelo ADO.NET e pelo EF Core, e sobretudo examinamos as diferenças de comportamento que pegam de surpresa quem vem de outro banco. São elas que fazem um sistema passar em todos os testes num banco e corromper dados no outro.

Um pouco de história

O MySQL foi criado em 1995 pelos suecos Michael "Monty" Widenius e David Axmark, e pela empresa MySQL AB. O nome vem de My, filha de Monty. Rápido, simples de instalar e gratuito, ele se tornou o "M" da sigla LAMP (Linux, Apache, MySQL e PHP), a pilha sobre a qual boa parte da web foi construída: Wikipedia, os primeiros anos do Facebook e o WordPress, que ainda hoje roda sobre ele.

Em 2008 a MySQL AB foi comprada pela Sun Microsystems, a mesma do Java da aula de história do C#, e em 2010 a Sun foi comprada pela Oracle. Preocupado com o futuro do projeto nas mãos de uma empresa que vendia um banco de dados concorrente, Monty criou em 2009 uma bifurcação de código aberto, o MariaDB, batizado em homenagem à sua outra filha, Maria. Hoje os dois coexistem: o MySQL, mantido pela Oracle, com versões de suporte longo (a série 9.7, lançada em abril de 2026, é a mais recente; a 8.4, usada nos exemplos desta aula, é a anterior e continua com suporte) e versões de inovação; e o MariaDB, que se afastou aos poucos, mas continua compatível na maior parte do uso cotidiano.

Para executar localmente, o Docker de sempre:

docker run --name livraria-mysql \
  -e MYSQL_ROOT_PASSWORD=senha-de-desenvolvimento \
  -e MYSQL_DATABASE=livraria \
  -p 3306:3306 \
  -v livraria-mysql-dados:/var/lib/mysql \
  -d mysql:8.4

O provedor: MySqlConnector

Para o ADO.NET, existem dois provedores principais. O MySql.Data, da Oracle, e o MySqlConnector, um projeto independente de código aberto sob licença MIT. A recomendação da comunidade .NET é o MySqlConnector: ele implementa operações assíncronas de verdade, com entrada e saída de rede sem bloquear threads, como exige "async e await". O provedor da Oracle, por muitos anos, oferecia métodos Async que por dentro executavam de forma síncrona. O MySqlConnector também tem desempenho superior e uma licença mais simples de adotar.

dotnet add src/Livraria.Dados package MySqlConnector

O uso é o mesmo ADO.NET de sempre, e o Dapper funciona sobre ele sem mudanças. O relatório de faturamento por autor:

using Dapper;
using MySqlConnector;

await using var conexao = new MySqlConnection(cadeia);

var faturamento = await conexao.QueryAsync<(string Autor, decimal Total)>("""
    SELECT a.nome, SUM(v.quantidade * p.preco)
    FROM vendas v
    JOIN produtos p ON p.id = v.produto_id
    JOIN autores  a ON a.id = p.autor_id
    GROUP BY a.nome
    ORDER BY 2 DESC
    """);

Como o PostgreSQL, o MySQL tem um tipo decimal exato, DECIMAL(10,2), e o preço fica em reais, sem conversão para centavos.

O EF Core com MySQL

O provedor mais usado do EF Core para MySQL é o Pomelo.EntityFrameworkCore.MySql, mantido pela comunidade e construído sobre o MySqlConnector. A Oracle mantém um provedor próprio, o MySql.EntityFrameworkCore. Um aviso honesto: por ser mantido pela comunidade, o Pomelo historicamente leva algum tempo para lançar a versão compatível com cada nova versão principal do EF Core. Antes de atualizar o EF Core num projeto que usa MySQL, verifique se já existe uma versão do Pomelo compatível, na página do projeto. Em projetos novos, confira a compatibilidade antes de escolher a versão do .NET e do EF Core. O aviso não é teórico: em outubro de 2026, a versão mais recente do Pomelo no NuGet ainda é a 9.0.0, para o EF Core 9, e não existe uma para o EF Core 10, o deste curso. Com o EF Core 10, a alternativa hoje é o provedor da Oracle (MySql.EntityFrameworkCore, já na série 10); com o Pomelo, o projeto de dados precisa ficar no EF Core 9, que ainda roda no .NET 10.

A configuração exige informar a versão do servidor, porque o SQL gerado muda conforme os recursos disponíveis:

DbContextOptions<LivrariaContext> opcoes = new DbContextOptionsBuilder<LivrariaContext>()
    .UseMySql(cadeia, new MySqlServerVersion(new Version(8, 4, 0)))
    .UseSnakeCaseNamingConvention()
    .Options;

Existe também ServerVersion.AutoDetect(cadeia), que descobre a versão conectando-se ao banco. É prático, mas exige uma conexão durante a configuração e falha se o banco estiver fora do ar nesse momento. Declarar a versão explicitamente torna a inicialização previsível.

A configuração do preço é a mesma do PostgreSQL, HasPrecision(10, 2), e vira uma coluna decimal(10,2). Entidades, relacionamentos, tipos próprios, rastreamento de mudanças e concorrência otimista funcionam como nas aulas anteriores. As diferenças estão nos detalhes, e é deles que tratamos agora.

Diferença nº 1: comparações que ignoram acentos

No MySQL, cada coluna de texto tem um conjunto de caracteres (charset) e um agrupamento (collation), a regra que decide como os textos são comparados e ordenados. Desde o MySQL 8.0, o padrão é utf8mb4_0900_ai_ci, e o sufixo diz tudo: ai significa accent insensitive (ignora acentos) e ci, case insensitive (ignora maiúsculas). Assim, sem nenhuma configuração:

SELECT 'sertao' = 'Sertão';   -- 1 (verdadeiro!)

Para a busca da Livraria, isso é ótimo: a pesquisa por "sertao" encontra "Grande Sertão" sem extensão nenhuma, o oposto do PostgreSQL. Mas pense no índice único sobre o nome do autor, de "EF Core: Relacionamentos e Migrations". Com esse agrupamento, "José" e "Jose" são o mesmo valor, e cadastrar o segundo falha com erro de duplicidade, mesmo que sejam pessoas diferentes. O mesmo vale para qualquer comparação de igualdade, junção ou GROUP BY sobre texto: o comportamento que parece óbvio num banco é outro aqui.

Quando uma coluna precisa distinguir acentos e maiúsculas, como um código ou uma chave, declare outro agrupamento para ela:

builder.Property(p => p.CodigoInterno).UseCollation("utf8mb4_0900_as_cs");   // sensível a acentos e caixa

Diferença nº 2: utf8 não é UTF-8

Aqui está uma das armadilhas mais famosas do MySQL. Por razões históricas, o conjunto de caracteres chamado utf8 no MySQL aceita no máximo 3 bytes por caractere, e não os 4 que o UTF-8 real permite. Ele não armazena emojis nem vários caracteres de escritas asiáticas e símbolos matemáticos. Tentar gravar "Livro favorito 📚" numa coluna utf8 produz um erro ou, em configurações antigas e permissivas, um texto truncado em silêncio. O UTF-8 completo tem outro nome no MySQL: utf8mb4. Nas versões 8.x ele já é o padrão, mas bancos e tabelas criados em versões antigas, e migrados desde então, frequentemente ainda usam o utf8 de 3 bytes (hoje chamado explicitamente de utf8mb3). Em qualquer sistema MySQL que você herdar, confira o conjunto de caracteres das tabelas.

Diferença nº 3: modos de SQL e o silêncio

Historicamente, o MySQL era permissivo: um texto maior que a coluna era truncado, uma data inválida virava 0000-00-00, e um número fora da faixa virava o maior valor possível, tudo com, no máximo, um aviso que ninguém lia. As versões atuais ativam por padrão os modos estritos (STRICT_TRANS_TABLES, ONLY_FULL_GROUP_BY e outros), que transformam esses casos em erros, como o PostgreSQL faz. Mas servidores antigos e hospedagens compartilhadas às vezes rodam com modos permissivos configurados para manter compatibilidade com sistemas legados. Em produção, verifique com SELECT @@sql_mode;. Um banco que "conserta" dados inválidos em silêncio é um banco que perde dados em silêncio.

Diferença nº 4: motores de armazenamento

O MySQL permite escolher, por tabela, o motor de armazenamento. O padrão moderno é o InnoDB, com transações, chaves estrangeiras e bloqueio por linha. O antigo MyISAM, padrão até 2010, não tem transações nem chaves estrangeiras: um REFERENCES numa tabela MyISAM é aceito e simplesmente ignorado, como no SQLite sem o PRAGMA de "SQL Essencial com SQLite", e um ROLLBACK não desfaz nada. Tabelas criadas pelo EF Core usam InnoDB. Tabelas de sistemas antigos podem não usar. Confira com SHOW TABLE STATUS.

Diferença nº 5: datas e o ano de 2038

O MySQL tem dois tipos para data e hora. DATETIME guarda o valor como foi informado, sem fuso. TIMESTAMP converte para UTC ao gravar e de volta ao fuso da sessão ao ler, mas tem uma limitação herdada dos inteiros de 32 bits do mundo C: seu valor máximo é 19 de janeiro de 2038, às 03:14:07 UTC. Uma data de vencimento, uma assinatura de 20 anos ou um agendamento distante já não cabem nele. A recomendação atual é usar DATETIME(6), com precisão de microssegundos, e aplicar a mesma disciplina do PostgreSQL: gravar sempre em UTC e converter para o fuso do usuário só na exibição. O provedor do EF Core mapeia DateTime e DateTimeOffset para datetime(6).

Diferença nº 6: sem arrays nativos

O MySQL não tem o tipo array do PostgreSQL. Para listas simples como os gêneros, há dois caminhos. O relacional clássico, com uma tabela de gêneros e uma de ligação, é o mais consultável. O pragmático é guardar a lista numa coluna do tipo JSON, que o MySQL tem desde a versão 5.7, com um conversor de valor como o dos centavos em "Entity Framework Core":

using System.Text.Json;

builder.Property(p => p.Generos)
       .HasConversion(
           generos => JsonSerializer.Serialize(generos, (JsonSerializerOptions?)null),
           json => JsonSerializer.Deserialize<string[]>(json, (JsonSerializerOptions?)null) ?? Array.Empty<string>())
       .HasColumnType("json");

O (JsonSerializerOptions?)null explícito tem uma razão técnica: o conversor é uma árvore de expressão, como as consultas de "Entity Framework Core", e árvores de expressão não aceitam chamadas que omitem argumentos opcionais. É preciso informar o valor explicitamente. O custo desse caminho: com um conversor genérico, o EF Core enxerga a coluna apenas como texto, e consultas como p.Generos.Contains("romance") não são traduzidas para os operadores de JSON do MySQL. Filtrar por gênero exigiria SQL escrito à mão com JSON_CONTAINS, ou a tabela de ligação. Diferenças de recursos entre bancos aparecem exatamente aqui: o mesmo domínio, e um mapeamento que precisa ser decidido de novo para cada banco.

Diferenças menores de sintaxe

Para quem escreve SQL à mão, com ADO.NET ou Dapper, algumas diferenças aparecem com frequência:

  • Identificadores vão entre crases no MySQL (` produtos ) e entre aspas duplas no PostgreSQL ("produtos"`). Com nomes em minúsculas e sem espaços, como os da convenção snake_case, nenhum dos dois precisa de delimitadores.
  • Chaves automáticas: AUTO_INCREMENT no MySQL, GENERATED ... AS IDENTITY no PostgreSQL e INTEGER PRIMARY KEY no SQLite.
  • Inserir ou atualizar (upsert): INSERT ... ON DUPLICATE KEY UPDATE no MySQL, e INSERT ... ON CONFLICT ... DO UPDATE no PostgreSQL e no SQLite.
  • Nomes de tabelas diferenciam maiúsculas no MySQL em Linux e não as diferenciam em Windows, por depender do sistema de arquivos. Um sistema desenvolvido no Windows com Produtos e produtos misturados quebra ao ser publicado num servidor Linux. Nomes sempre em minúsculas evitam o problema.

Juntando tudo: a mesma Livraria, outro banco

Com o contexto configurado para o Pomelo e as migrations criadas para o MySQL:

await using (var contexto = new LivrariaContext(opcoes))
{
    var machado = new Autor("Machado de Assis");
    var dom = new LivroImpresso("Dom Casmurro", machado, 59.90m, pesoGramas: 350, estoque: 10);
    dom.ClassificarEm("Romance", "Realismo");

    var guimaraes = new Autor("Guimarães Rosa");
    var sertao = new LivroImpresso("Grande Sertão: Veredas", guimaraes, 89.00m, pesoGramas: 1200, estoque: 2);

    contexto.AddRange(dom, sertao);
    await contexto.SaveChangesAsync();
}

await using (var contexto = new LivrariaContext(opcoes))
{
    // Nenhuma função especial: o agrupamento padrão já ignora acentos e maiúsculas
    var busca = await contexto.Produtos
        .Where(p => p.Titulo.Contains("sertao"))
        .Select(p => p.Titulo)
        .ToListAsync();
    Console.WriteLine($"Busca por \"sertao\": {string.Join(", ", busca)}");

    try
    {
        contexto.Add(new Autor("Guimaraes Rosa"));   // sem acento: outro autor?
        await contexto.SaveChangesAsync();
    }
    catch (DbUpdateException ex)
    {
        Console.WriteLine($"Recusado pelo índice único: {ex.InnerException?.Message}");
    }

    decimal valorEmEstoque = await contexto.Produtos.OfType<LivroImpresso>().SumAsync(l => l.Estoque * l.Preco);
    Console.WriteLine($"Valor em estoque: {valorEmEstoque:C}");
}

A busca por "sertao" encontra "Grande Sertão: Veredas" com um simples Contains, traduzido para LIKE, porque o agrupamento ignora acentos. O mesmo código no PostgreSQL não encontraria nada. E a tentativa de cadastrar "Guimaraes Rosa" é recusada pelo índice único, porque, para esse agrupamento, é o mesmo nome de "Guimarães Rosa". Neste caso, a recusa até é o que queremos, porque evita um cadastro duplicado por erro de digitação. Mas é uma regra de negócio que você não escreveu: ela veio do padrão do banco, e em outro banco não existiria. O valor em estoque, R$ 777,00, é idêntico ao do PostgreSQL, com decimais exatos.

Duas aulas, dois bancos relacionais, e um padrão que se repete: o domínio não mudou nada, o SQL básico quase não mudou, e os detalhes de comportamento mudaram o bastante para exigir atenção a cada troca. Todos os bancos que vimos até aqui, porém, compartilham o mesmo modelo: tabelas com colunas fixas, relacionamentos por chaves e consultas em SQL. Existe outra família de bancos de dados, construída sobre uma ideia diferente: em vez de espalhar um pedido por quatro tabelas e remontá-lo com junções, guardá-lo inteiro, como um documento, do jeito que o programa o enxerga.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Na Livraria em MySQL, o cadastro do autor "José de Alencar" falha porque já existe um "Jose de Alencar", cadastrado por engano. Explique a causa e mostre como tornar o nome do autor sensível a acentos. Isso é desejável aqui?

Ver resposta

✓ Resposta: A coluna nome usa o agrupamento padrão utf8mb4_0900_ai_ci, que ignora acentos e maiúsculas. Para ele, "José de Alencar" e "Jose de Alencar" são o mesmo valor, e o índice único recusa o segundo como duplicado. Para tornar a coluna sensível a acentos (mantendo a insensibilidade a maiúsculas, que é razoável para nomes), declare outro agrupamento na configuração e gere uma migration:

builder.Property(a => a.Nome)
       .IsRequired()
       .HasMaxLength(120)
       .UseCollation("utf8mb4_0900_as_ci");   // "as": sensível a acentos; "ci": insensível a maiúsculas

Se isso é desejável depende do negócio, e aqui provavelmente não é. O cadastro "Jose de Alencar" sem acento é quase certamente um erro de digitação, e o índice que o recusa está protegendo contra um autor duplicado. A correção certa é consertar o cadastro existente, e não afrouxar a regra. Tornar a coluna sensível a acentos faria sentido para códigos ou identificadores, em que "a" e "á" são símbolos distintos. O ponto do exercício é que essa decisão precisa ser tomada, e não herdada por acaso do padrão de um banco específico.

Exercício 2

Um sistema antigo em MySQL passou a recusar títulos com emojis depois que a Livraria começou a receber cadastros pelo celular. Explique a causa provável e a correção.

Ver resposta

✓ Resposta: A tabela, ou a coluna, foi criada com o conjunto de caracteres utf8, que no MySQL é um apelido histórico do utf8mb3: no máximo 3 bytes por caractere. Emojis como "📚" ocupam 4 bytes em UTF-8 e não cabem, e o servidor recusa a gravação (com os modos estritos) ou trunca o texto (sem eles). A correção é converter a tabela para o UTF-8 completo:

ALTER TABLE produtos CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

Três cuidados acompanham a mudança. A conversão reescreve a tabela e pode demorar em tabelas grandes, então deve ser feita numa janela de manutenção. Índices sobre colunas de texto longas podem ultrapassar o limite de tamanho de chave, porque cada caractere passa a ocupar até 4 bytes em vez de 3. E a conexão também precisa usar utf8mb4, o que o MySqlConnector faz por padrão. Em bancos novos, com MySQL 8.x, o utf8mb4 já é o padrão, e o problema só aparece em sistemas herdados, que é justamente onde a maioria das pessoas encontra o MySQL.

Exercício 3

Um sistema de assinaturas grava a data de vencimento numa coluna TIMESTAMP. Uma assinatura de 15 anos, contratada em 2026, falhou ao ser gravada. Explique e proponha o tipo correto.

Ver resposta

✓ Resposta: O TIMESTAMP do MySQL armazena internamente o número de segundos desde 1º de janeiro de 1970 num inteiro de 32 bits com sinal, cujo valor máximo corresponde a 19 de janeiro de 2038, às 03:14:07 UTC. Uma assinatura de 15 anos contratada em 2026 vence em 2041, fora da faixa, e a gravação falha (ou, em modos permissivos, grava um valor inválido). É o chamado problema do ano 2038, o mesmo que afeta programas em C que usam time_t de 32 bits. O tipo correto é DATETIME(6), que vai até o ano 9999, com a disciplina de gravar sempre em UTC. No EF Core com o Pomelo, uma propriedade DateTimeOffset ou DateTime já é mapeada para datetime(6). O problema aparece em tabelas criadas à mão ou herdadas. Vale a mesma lição de "Cada Valor Tem um Tipo": a faixa de um tipo é parte do contrato, e ignorá-la produz erros que só aparecem quando os dados alcançam o limite.

Exercício 4

Por que ServerVersion.AutoDetect(cadeia) pode causar problemas na inicialização de uma aplicação em produção, e qual a alternativa?

Ver resposta

✓ Resposta: AutoDetect abre uma conexão com o banco durante a configuração do contexto, para consultar a versão do servidor. Isso cria três problemas. Primeiro, se o banco estiver indisponível no momento em que a aplicação sobe, por exemplo durante uma manutenção ou um reinício simultâneo dos serviços, a própria inicialização falha, em vez de a aplicação subir e tentar reconectar quando precisar. Segundo, cada inicialização paga uma ida ao banco, o que atrasa a subida de várias instâncias. Terceiro, o comportamento passa a depender do servidor encontrado naquele momento: se o banco for atualizado, o SQL gerado muda sem nenhuma mudança no código e sem nenhum teste. A alternativa é declarar a versão explicitamente, new MySqlServerVersion(new Version(8, 4, 0)), tratada como configuração, lida por exemplo de uma variável de ambiente junto com a cadeia de conexão. A inicialização fica previsível e não depende do banco, e uma atualização do servidor vira uma mudança consciente de configuração, testada antes de chegar à produção.

Exercício 5

Escreva, para o MySQL, o PostgreSQL e o SQLite, o comando que insere uma quantidade no estoque de reposição de um produto ou, se o produto já tiver registro, soma a quantidade ao valor existente. Considere uma tabela reposicoes(produto_id PRIMARY KEY, quantidade).

Ver resposta

✓ Resposta: No MySQL:

INSERT INTO reposicoes (produto_id, quantidade) VALUES (@id, @q)
ON DUPLICATE KEY UPDATE quantidade = quantidade + VALUES(quantidade);

No PostgreSQL:

INSERT INTO reposicoes (produto_id, quantidade) VALUES (@id, @q)
ON CONFLICT (produto_id) DO UPDATE SET quantidade = reposicoes.quantidade + EXCLUDED.quantidade;

No SQLite (versão 3.24 ou posterior):

INSERT INTO reposicoes (produto_id, quantidade) VALUES ($id, $q)
ON CONFLICT (produto_id) DO UPDATE SET quantidade = quantidade + excluded.quantidade;

Os três fazem a mesma coisa: tentam inserir e, se a chave primária já existir, atualizam o registro existente, numa única instrução atômica. É a mesma lição de concorrência de "ADO.NET": sem uma janela entre "verificar se existe" e "inserir ou atualizar". As diferenças são de sintaxe. O MySQL detecta qualquer chave única duplicada e usa VALUES(coluna) para o valor proposto (nas versões mais recentes, a forma recomendada é um apelido para a linha nova, e VALUES() foi descontinuado). O PostgreSQL e o SQLite exigem indicar a coluna do conflito e usam a pseudotabela excluded para os valores propostos. Os marcadores de parâmetro também mudam conforme o provedor. É um exemplo pequeno de por que um sistema que precisa suportar vários bancos acaba concentrando o SQL específico de cada um num lugar só, o assunto da última aula desta fase.

Comentários

Mais em Linguagem C#

O aplicativo do cliente em .NET MAUI: Shell, listas, login no celular e acompanhamento de pedidos
O aplicativo do cliente em .NET MAUI: Shell, listas, login no celular e acompanhamento de pedidos

O aplicativo do cliente para iPhone e Android em .NET MAUI: Shell com abas e…

Entity Framework Core: Objetos no Banco Sem Escrever SQL
Entity Framework Core: Objetos no Banco Sem Escrever SQL

O domínio da Livraria persistido com Entity Framework Core e SQLite: DbContext…

Interop com C: Chamando Código Nativo com P/Invoke e unsafe
Interop com C: Chamando Código Nativo com P/Invoke e unsafe

Como chamar uma biblioteca em C a partir do C#: LibraryImport, conversão de…