Uma gentil introdução ao uso de banco de dados orientados a grafos com Neo4j

Veja como mapear o domínio de sua aplicação pensando em nós e relacionamentos simples com um banco de dados orientado a grafos.

Mário Meyrelles
Accendis Tech
28 min readDec 27, 2015

--

Primeiramente, gostaria de dizer que estou escrevendo este post como uma tentativa de começar a retribuir toda a ajuda que tive durante a minha carreira. Graças a muitos artigos de blogs, livros, google, StackOverflow e conversas com amigos eu pude evoluir significativamente como um desenvolvedor de software. Agradeço profundamente por ter uma chance de estar vivo nesta época, com estas pessoas, com estes desafios e com esta multitude de coisas acontecendo ao mesmo tempo nesta profissão. Ser programador é uma profissão que exige um preparo emocional grande para suportar o stress, mas ser programador também é ter a chance de estar sempre apto a descobrir o que é novo, como uma criança a descobrir o mundo. E descobrir o que há de novo é o que deveria mover a vida de todos os programadores…

Escolhi usar uma linguagem bem mais informal e simples para facilitar o entendimento da leitura e claro, para não ser mais um cara chato a escrever coisas na internet. Resolvi ser muito prolixo e tentei usar o máximo de exemplos para tentar deixar as coisas mais claras.

Para entender este post você precisa ter experiência como desenvolvedor ou precisa ter experiência com banco de dados. E talvez faça sentido salvar este texto para ir lendo com calma, porque ele é muito longo :)

Vamos lá!

Introdução, Algumas Ideias e Um Pouco de Contexto

“Afinal, do que você tá falando? Banco de dados nosql? Grafos? Ah, tudo isso é muito complicado e é uma loucura. Não tô afim de ficar escolhendo qual banco usar! Tô fora!”

A vasta maioria dos sistemas que foram ou estão sendo criados hoje guardam seus dados em bancos de dados relacionais como o Oracle, SQL Server, MySQL, Postgres e muitos outros. Eles usam o SQL como linguagem para fazer suas consultas e manipulação de dados (embora o dialeto do SQL possa variar). Este tipo de armazenamento de dados praticamente se tornou unanimidade no mercado e até o surgimento dos bancos de dados “nosql”, nenhum arquiteto considerava outra forma de persistência de dados para a sua aplicação.

Com o surgimento dos bancos de dados “nosql” (https://pt.wikipedia.org/wiki/NoSQL), outras formas de persistência de dados surgiram para casos de uso especializados. Engines focados em guardar pares chave-valor, documentos json, big tables e outros acabaram aumentando o leque de opções para os desenvolvedores e arquitetos. Um dos players que surgiram foram bancos de dados orientados a grafos e o mais proeminente hoje, fim de 2015, é o Neo4j, foco deste post. Para instalar e aprender mais sobre o Neo4j e os principais detalhes do funcionamento do Neo4j, o site é muito bom é vale a pena explorar: http://neo4j.com/. Não vou gastar seu tempo mostrando como se instala e configura o Neo4j porque esta informação é extremamente fácil de conseguir.

Diferentemente de um banco de dados relacional comum, o banco orientado a grafos é muito mais simples de desenhar. Não precisa de tabelas. Não precisa de chaves primárias (embora seja útil criar unique indexes para nós). Não precisa de um design complexo de tabelas para começar a incluir os dados. Você consegue começar simplesmente…. incluindo novos dados da forma que você quiser. Para criar um aluno, simplesmente, basta criar um nó (opcionalmente do tipo Aluno) e criar as propriedades do aluno neste nó do grafo. E mais nada. Não precisa criar a tabela Aluno, não precisa se preocupar com tipos de dados da entidade Aluno, não precisa se preocupar neste momento com quais outras entidades o Aluno vai se relacionar. Se no futuro você decidir que a entidade Aluno deve se relacionar à entidade Disciplina, você simplesmente cria uma relação que aponte de Aluno para Disciplina, podendo qualificá-la como quiser, por exemplo, dizendo que o aluno cursou a disciplina no primeiro semestre de 2014 e teve uma nota final de 8.5. De novo, sem se preocupar em construir tabelas, foreign keys, tabelas de junção e demais artefatos que normalmente construiríamos no banco de dados relacional.

Para consultar os dados, basta declarar um padrão de busca usando a linguagem Cypher, que é muito similar ao SQL. O Cypher é a linguagem oficial de consultas do Neo4j e permite que se crie, modifique e procure dados em uma estrutura baseada em um grafo de informações e relacionamentos.

Uma busca para o exemplo acima e retorno seria mais ou menos assim:

match (a:Aluno {RA: “140001”})-[r:Cursou{Semestre: “2014S1”}]->(d:Disciplina)
where r.NotaFinal >= 5 and r.Trancou = false
return *

O resultado pode ser exposto como um grafo ou um resultset. Vale ressaltar que é possível escolher exatamente o que será retornado pela consulta.

Resultado de uma consulta em forma de grafo
Resultado da consulta em formato tabular

Neste artigo vou tentar mostrar que o banco de dados orientado a grafos é muito útil para dados que estejam bastante interligados e também é muito útil para construir modelos de dados que realmente atendam ao domínio, fáceis de visualizar e compreender até por quem não é desenvolvedor. Este é um princípio muito importante da teoria de Domain-Driven Design. O modelo deve representar o mais próximo possível o business, e o modelo precisa ser comunicado com os desenvolvedores usando uma linguagem comum e clara. Ou seja, provavelmente você e os analistas usam diagramas para ilustrar as relações entre as principais entidades do sistema. E provavelmente, a mesma ideia básica desenhada na lousa poderá ser aplicada no Neo4j, diminuindo assim a impedância implícita que normalmente existe entre as entidades e relacionamentos que o cliente vê versus o modelo efetivo de banco de dados que será implementado.

Ah! Não posso esquecer que muitas vezes certas consultas acabam ficando relativamente grandes, complexas, custosas e cheias de joins que precisam ser otimizados. Eu, atualmente, trabalho em um sistema que usa o tempo todo um join com 7, 8 ou 10 tabelas para poder fazer tudo. Quando os dados estão normalizados, o acesso a dados se torna naturalmente mais lento e complexo, fazendo com que os DBAs tenham que sair criando índices para as mais diversas queries, ou mesmo, que desnormalizem os dados para aumentar a velocidade de certas queries usando jobs para cache de resultados, reeengenharia das tabelas, views indexadas, procedures que quebrem o processamento em partes, tabelas temporárias, etc… aquele dia-a-dia que nós já conhecemos há tempos. O que acontece com o tempo é que o modelo de dados fica cada vez mais complexo e por conseguinte, mais distante do que o analista visualizou no passado. O grafo representado pelas tabelas não comunica mais nada para ninguém e os desenvolvedores precisam de um histórico e ajuda contínua de desenvolvedores mais experientes para conseguir trabalhar com este modelo de dados. Não gostaria sequer de lembrar (ah, lembrei…) que há situações ainda mais absurdas onde as tabelas, campos e demais objetos do banco sequer têm nomes e sim, alguma numeração absurda que torna o processo de aprendizado do modelo ainda mais impossível.

Quando se usa um banco orientado a grafos, é praticamente indiferente quantos nodes você irá atacar para uma determinada consulta. Este é o maior selling point deste tipo de banco de dados, o que o faz a escolha mais adequada para dados com muitas relações entre os nós do grafo, como redes sociais e sistemas de recomendação de compras. Analytics também são casos de uso legais para uso de bancos orientados a grafos.

Por último, gostaria de comentar que esta ida para bancos de dados orientados a documentos está vendendo a ideia errada da super-simplificação de modelo de dados. Desnormalizar o banco de dados e colocar todo o grafo de relações em formato json/whatever em algumas collections de documentos é o caminho que muitas pessoas estão colocando toda a energia. Eu acho um equívoco migrar de um modelo de dados relacional para um modelo de dados orientado a documentos sem ter um racional muito claro, uma necessidade com modelo de dados que te force mesmo a ter que ter uma liberdade maior ou então, uma real imprevisibilidade do formato das entidades. Por exemplo, faz sentido colocar um cadastro de produtos de um e-commerce em um banco de dados orientado a documentos, porque, a priori, não sabemos quais são os campos e tipos de produtos que poderemos vender na loja. É um schema totalmente mutável e faz muito mais sentido guardar este cadastro no MongoDB e não no Oracle. Se você tem apenas uma lista de pedidos e itens de pedido para uma loja que vende pouquíssimos tipos diferentes de produtos (ex: uma livraria que só vende livros pela web), mantenha os dados no modelo relacional até que alguma outra força do seu business peça que você evolua partes do modelo para outra tecnologia não-relacional, nosql.

Minha recomendação é usar um modelo de dados relacional com features não-relacionais por padrão ao desenhar sua solução, migrando para modelos não-relacionais quando o business exigir situações mais complexas. Por exemplo, você pode usar um banco de dados SQL Server com suporte a XML (que suporta queries em XPath e uma forte capacidade de trabalhar com processamento de XML), Postgres (que tem, desde a versão 9.4 um forte suporte a JSONB, que é o formato JSON otimizado e uma sintaxe bacana para juntar dados em documentos com tabelas) ou Oracle, que suporta também XML (e muitas outras features bacanas!!!). Ao optar por um modelo relacional, você acaba se beneficiando ao se aproveitar de mais de 20 anos de investimento em ferramentas, automação, frameworks e conhecimento público sobre um problema que já é muito bem conhecido e relativamente bem resolvido.

Não se esqueça também que ao usar uma arquitetura orientada a serviços menores, é possível ter partes de sua aplicação usando diferentes tipos de bancos de dados e arquiteturas, flexibilizando brutalmente a decisão de acesso a dados para cada microserviço (http://microservices.io/patterns/microservices.html) da aplicação, que normalmente, corresponde a um bounded context (https://msdn.microsoft.com/en-us/magazine/jj883952.aspx - leitura relativamente complexa) específico do projeto. Ou seja, posso fazer o cadastro de produto de uma forma, processamento de ordens de outra forma, cache de outra forma e com isso, usar o conceito de persistência poliglota para sua solução.

Uma Pequena Introdução ao Neo4j e Cypher.

Tá, como funciona esse negócio? Tô aqui lendo este artigo durante o expediente e até agora você só me enrolou com esse bla bla bla…

Como mencionei antes, as coisas no Neo4j não são nada burocráticas. É chegar, criar o node, a relação, colocar alguns dados, fazer suas queries e ir para o bar tomar sua cerveja :) Mas para que tudo isso seja possível, é preciso conhecer e brincar um pouco com a linguagem Cypher do Neo4j. Além disso é preciso entender como os dados são guardados neste banco de dados.

Para salvar dados você tem a disposição o seguinte:

  • (node): unidade fundamental responsável por guardar dados de uma entidade. Um nó pode ter várias propriedades. Um nó pode ter opcionalmente tipos (labels). Por exemplo, o nó Aluno pode ter as propriedades NomeCompleto, DataNascimento e pode ter os labels Aluno, ExAluno, etc. É sempre recomendável colocar um tipo para o nó, pois ajuda muito ao fazer consultas, ao visualizar dados de consultas e também, ajuda a a criar um modelo bacana para ser mostrado para o cliente.
  • Relação (relationship): unidade fundamental responsável por gravar como um nó se relaciona com outro. Também pode ter propriedades. Por exemplo, é possível criar uma relação entre um nó do tipo Aluno e um nó do tipo Disciplina e criar uma relação do tipo Cursou, com várias propriedades. Note que é possível criar mais de uma relação para um mesmo par de nodes. A característica mais importante de uma relação é que ela possui direção. Por exemplo, a relação “Cursou” só faz sentido quando vai de Aluno para Disciplina. Ela tem uma direção clara. Há certos tipos de relação que até podem ser bidirecionais na prática, mas só é possível criar relações unidirecionais. Mas claro, a consulta pode ignorar da direção caso você queira (por exemplo, tanto faz a direção do relacionamento “Conhece” entre duas pessoas numa consulta).

Para criar novos dados no Neo4j só precisa disso!

Para fazer consultas é os principais comandos são:

  • MATCH: responsável por montar um padrão de busca para que o engine traga os nós e relações de interesse. Equivalente ao SELECT do banco de dados relacional. O padrão principal de um MATCH é ()-[]-(). Ou seja, (um nó)-[relacionado]->(com outro nó). Este padrão visual é proposital e ajuda a visualizar qual padrão você está buscando no seu grafo. É possível modificar este padrão base à vontade para satisfazer certas queries, e inclusive, é possível incluir mais nodes e relações neste padrão base. Mas a ideia é sempre a mesma: trazer nós e relações de acordo com um certo padrão.
  • CREATE: responsável por criar nós e relacionamentos entre eles.
  • MERGE: é utilizado para criar nós e relações apenas quando eles não existem previamente na base. Isso é particularmente útil durante importação de dados… não queremos criar 10 vezes a mesma entidade do tipo Escola na base, não é mesmo?
  • SET: muda as propriedades de uma relação ou nó.
  • RETURN: responsável por trazer as consultas no formato esperado para o cliente. Vale notar que saber trabalhar com o RETURN é interessante porque é possível retornar linhas ou então, um grafo completo. Além disso, é possível fazer agregações como somas, contagens, médias, concatenação em listas e muito mais.
  • DELETE: responsável por apagar nó ou relação inteira. Note que pode ser que você não consiga apagar um nó que esteja envolvido em outras relações. Se quiser apagar também as relações deste nó a ser excluído, use o DETACH DELETE.
  • REMOVE: remove uma propriedade de um nó ou relação. Também serve para remover um label do nó.
  • WITH: como o Cypher suporta fazer consultas que usem resultados de consultas prévias, o WITH serve para expor o resultado de um MATCH anterior para um próximo MATCH ou talvez, para um RETURN um pouco mais complexo. É a chave para quebrar patterns complexos em queries menores possivelmente mais rápidas.

Para trabalhar com o Neo4j, é fundamental ter o ref-card aberto: http://neo4j.com/docs/2.1/cypher-refcard/. A documentação também é bem simples de entender e há muitos exemplos de consultas no StackOveflow.

Vale dizer que o Cypher está em uma rápida evolução e a cada versão há alguma melhoria ou nova funcionalidade. Portanto, vale sempre a pena estar com a versão mais recente do Neo4j. Antes de mostrar o exemplo principal, será mostrado alguns exemplos preliminares. Nunca se esqueça que os nomes e variáveis são case-sensitive. O editor mostra os erros claramente e portanto, é muito rápido saber quando algo está errado e como corrigir o erro.

Criar um nó sem label:

CREATE (n {nome: “Usuário 1”})
A execução de um comando create não retorna nada, mas mostra o que foi efetivamente criado. Isso é importante para saber se estamos criando mais coisas do que deveríamos ou se não estamos criando o que tínhamos a intenção de criar :)

Consultar o nó recém criado:

MATCH (n {nome: “Usuário 1”}) return n
Um nó sem label é difícil de identificar no grafo. A query deste exemplo pede todos os nós que tenham a propriedade nome com o valor de “Usuário 1”.
A mesma consulta acima pode ser vista de forma tabular, o que é interessante para retornar para aplicações que se comuniquem com o Neo4j. A visualização gráfica é boa para quem está desenvolvendo as consultas. Neste exemplo, também podemos ver que a tentativa anterior deu erro porque eu usei a letra N maiúscula no comando RETURN, mas o “alias” definido no comando MATCH usava o n minúsculo.
Esta terceira perspectiva mostra quais foram os comandos enviados para o Ne04j pelo browser. É possível inspecionar detalhadamente o que é enviado e o que é retornado pela API REST do Neo4j.

Criar alguns nós e algumas relações entre eles:

Para montar um segundo exemplo mais construtivo, vamos criar um pequeno grafo com as autorizações de acesso aos módulos de um sistema. No caso abaixo, estamos criando 2 usuários, criando 2 módulos do sistema (compras e financeiro) e estamos definindo 4 relações descrevendo como é o perfil de acesso de cada usuário a cada módulo. Uma grande vantagem do Cypher é a legibilidade das consultas, o que faz com que grande parte dos usuários entendam facilmente o que está acontecendo.

CREATE (u1:Usuário {Nome: “Usuário 1”, Id: 1 })
CREATE (u2:Usuário {Nome: “Usuário 2”, Id: 2 })
CREATE (m1:ModuloSistema {Nome: “Compras”})
CREATE (m2:ModuloSistema {Nome: “Financeiro”})
CREATE (u1)-[r1:PosssuiAcesso {NívelAcesso: “escrita”}]->(m1)
CREATE (u1)-[r2:PosssuiAcesso {NívelAcesso: “leitura”}]->(m1)
CREATE (u2)-[r3:PosssuiAcesso {NívelAcesso: “administrador”}]->(m1)
CREATE (u2)-[r4:PosssuiAcesso {NívelAcesso: “escrita”}]->(m2)
Conforme esperado, foram criados os nós e relações esperados.

Algumas consultas usando o grafo acima:

Com este pequeno grafo, podemos ter uma pequena ideia inicial de como é fazer consultas com o Neo4j. Eu pessoalmente acho que é muito mais fácil aprender a fazer consultas em grafos porque não há complicadores como tabelas, joins e essas complexidades de bancos relacionais. Basta “desenhar” o padrão que você deseja e a consulta já está praticamente pronta. Coloquei nos comentários das fotos todos os detalhes para tentar deixar claro o que está acontecendo em cada print :)

MATCH (u:Usuário) return *
Lembra do padrão ()-[]-()? Neste caso só pedi o primeiro pedaço, (), pedindo ao engine que me retorne todos os nós com o label “Usuário”. Neste caso, como criamos apenas dois usuários, os dois vieram e foram plotados na tela.
MATCH (u:Usuário), (m:ModuloSistema) return *
Nesta consulta peço todos os nós que representam usuário juntamente com todos os nós que representam módulos de um sistema. O visualizador de grafo da ferramenta já é inteligente o suficiente para trazer os relacionamentos entre os nós para nos ajudar a visualizar melhor os dados. Legal né?
MATCH (u:Usuário{Id: 1}), (m:ModuloSistema) return *
Esta consulta é mais um exemplo de como trazer vários nós, usando filtros diferentes para cada conjunto de nós. No caso, pedi para trazer o usuário 1 e todos os módulos. Como a função “Auto-Complete” está ativa, a ferramenta desenha os relacionamentos (mesmo que eu não tenha os pedido) e informa abaixo que a consulta foi executada com 2 relacionamentos adicionais.
MATCH (n:Usuário)-[r:PosssuiAcesso]->(m:ModuloSistema) return *
Esta consulta usa o padrão ()-[]-(). Pedi para trazer os nós do tipo Usuário que estejam ligados aos nós do tipo ModuloSistema pelo relacionamento PossuiAcesso. Veja como a notação é concisa e clara! Este padrão é fácil de escrever e muito intuitivo. Se fôssemos fazer a mesma consulta em um banco de dados relacional, provavelmente teríamos que fazer uma junção de 3 tabelas… No caso, o retorno vem em forma de grafo e a ferramenta permite customizar o diagrama para mudar as cores e o texto que aparece nos relacionamentos.
MATCH (n:Usuário)-[r:PosssuiAcesso{NívelAcesso: “escrita”}]->(m:ModuloSistema) 
return n, r, m
Neste caso, estamos filtrando o grafo para mostrar só as relações com acesso de escrita. Mas, estranhamente, todo o grafo está retornando, dando a impressão que o filtro não teve efeito. É preciso tomar cuidado com o auto-complete ativo nestes casos (note que o grafo foi incrementado com 2 relações adicionais).
Usando a mesma consulta acima, trazemos agora só as partes do grafo que realmente interessam. Note que o auto-complete está desligado.
Esta é a mesma consulta, mas agora, representada em forma de dataset.
MATCH (n:Usuário)-[r:PosssuiAcesso{NívelAcesso: “escrita”}]->(m:ModuloSistema) 
return n.Nome, r.NívelAcesso, m.Nome
Nesta imagem temos uma ligeira modificação no RETURN, trazendo somente as propriedades de interesse para a aplicação. O retorno neste caso não pode ser mais um grafo, já que estamos trazendo um conjunto contendo só pedaços dos nó e relações. Talvez esta forma seja a mais interessante para montar relatório que aparecerão em algum sistema.

Basicamente, estas são as ferramentas básicas para você trabalhar com dados no Neo4j. Com o tempo você vai sentir necessidade de novos tipos de manipulação de dados e consultas. A continuidade deste artigo trará novas opções e possibilidades de consultas. Aguente firme aí!

Apresentação do Caso de Uso: Processo de Matrícula em Disciplinas da Unicamp

Não aguento mais ver exemplo de cesta de compras, e-commerce ou pedidos!

Neste exemplo vamos trabalhar com as disciplinas do curso de Física da Unicamp ,modalidade AA - Bacharelado em Física. Assim como em outros cursos de outras faculdades, existe uma árvore de cursos que você precisa cumprir para conseguir o diploma. E para poder cursar disciplinas mais avançadas, é necessário ser aprovado nos pré-requisitos anteriores. Todas as disciplinas do curso de Física estão listados aqui (http://www.dac.unicamp.br/sistemas/catalogos/grad/catalogo2015/proposta/sug4.html). Quando o aluno ingressa no primeiro semestre da faculdade, não pode escolher quais matérias irá cursar. Todos começam com o mesmo conjunto de matérias, mas a partir do semestre seguinte pode escolher quais e quantas matérias poderá cursar, sempre respeitando os pré-requisitos alcançados (há outras regras que dependem da nota do aluno mas não convém falar sobre elas agora). O que vamos focar aqui é no cálculo de pré-requisitos para as matérias do segundo semestre. Por exemplo, vamos investigar se um determinado aluno pode cursar a disciplina F 229 (Física II).

Para isso, vamos importar uma lista de cursos, vamos importar uma lista de alunos e vamos fazer algumas modificações no grafo para simular o progresso dos alunos de 2014.

O modelo de dados para este primeiro exemplo é um subset de um modelo maior, que contempla também quais são as salas de aula e quais são as turmas de uma determinada disciplina. O que acontece na vida real é que durante o período de matrícula, que ocorre entre o fim de um semestre e começo de outro, o aluno pode escolher entre várias turmas de uma mesma disciplina, que são ministradas por professores diferentes e horários diferentes. Por exemplo, o aluno que terminou o primeiro semestre precisa escolher qual turma cursará a matéria MA211 (Cálculo II). A turma A, MA211A, geralmente é ministrada de manhã. A MA211U é geralmente ministrada à noite. E assim por diante. A grande ideia é que os melhores alunos (aqueles com maior nota) tenham prioridade para escolher as turmas que lhes forem mais favoráveis, facilitando assim sua vida acadêmica! A coordenação de graduação aprova ou rejeita as requisições baseadas em vários critérios como preferência de curso (MA211A é para Engenharia, MA211B é para Física), coeficiente de rendimento (CR - média ponderada das notas) do aluno, número de pessoas já aprovadas para cursar a disciplina e etc. O processo de matrícula é uma espécie de jogo e você geralmente busca otimizar a estratégia de acordo com os seus achievements nos semestres anteriores. E claro, é preciso escolher as matérias com horários que não colidam entre si. Vale notar que aqui se você for um aluno ruim no primeiro semestre, sua vida acadêmica será a pior possível. Você pegará os piores professores e suas notas serão baixas para sempre. Ou seja, se eu pudesse dar só uma dica para os ingressantes, eu diria para fazer um primeiro e segundo semestres excelentes, sem adiantar muitas matérias.

Este modelo de dados é bem fácil de compreender, é fácil de complicar o quanto se queira e optei por este modelo porque posso usar em outros exemplos. E claro, escolhi este modelo porque eu vivenciei de perto esta (triste!) realidade durante meu curso.

Uma primeira aproximação deste modelo foi desenhada em um caderno para o exemplo deste artigo. Como o artigo fala de um banco orientado a grafos, é muito simples transportar este modelo para o Neo4j. Seguem abaixo as imagens dos meus rabiscos. As folhas estão um pouco amassadas porque meu filho estava brincando com elas.

Neste primeiro momento imaginei o cenário mais abrangente, pensando também em algumas consultas que eu gostaria que o sistema retornasse. Também observei algumas complexidades adicionais do modelo que acabei tirando para fazer este exemplo, como por exemplo, semestre de oferecimento de um curso (sim, há certos cursos que são apenas oferecidos no segundo semestre, o que significa que se o aluno vacilar automaticamente ficará mais um ano na faculdade).
Nesta outra parte eu quebrei o modelo em algumas partes para eu conseguir compreender melhor como funcionam certos requisitos como as salas de aula e pré-requisitos. E deixei o relacionamento entre Aluno e Curso um pouco mais fácil, usando o termo Cursou no passado e não Matriculado_em no presente, porque afinal, para o exemplo, quero apenas resolver um pequeno pedaço do domínio neste post. Isso é fundamental para tudo: sempre busque investir no modelo de dados que efetivamente vá resolver problemas com limites bem definidos. Muitas equipes criam sistemas usando o “e se” driven design. Não caia nessa!

Para concluir, neste artigo vamos montar só a relação de Aluno com Disciplina, e vamos complicar um pouco centralizando a questão dos pré-requisitos de um curso versus pré-requisitos obtidos, olhando para o histórico curricular do aluno.

Os pré-requisitos, como podemos ver aqui para a disciplina Cálculo Numérico (http://www.dac.unicamp.br/sistemas/catalogos/grad/catalogo2015/coordenadorias/0033/0033.html#MS211), podem ser compostos de vários grupos de pré-requisitos. Este fato é um complicador, pois força que o modelo tenha que considerar se o aluno alcançou uma ou mais combinações possíveis de pré-requisitos. Na vida real, os pré-requisitos têm validade e portanto, é preciso saber quando o aluno cursou o pré-requisito pois é possível haver uma mudança nas regras de dependência com o passar dos anos ao haver inclusões de novos catálogos de curso. Eu vivencei algumas mudanças de catálogo durante a minha estadia e por exemplo, a matéria Álgebra Linear passou a ser obrigatória, se não me engano, em 2004. Novamente isso pode ser um xadrez que pode te beneficiar, pois cursar Álgebra Linear mesmo sendo um aluno com catálogo do ano 2000 pode abrir portas para conseguir pré-requisitos mais simples e assim, se formar logo. Este tipo de complexidade deixei de fora para não complicar o entendimento do Neo4j.

Também, para este exemplo, tirei a lógica de montagem de grade de horários e de salas, porque este pedaço do domínio é bem mais complexo, sendo mais adequado para outros tipos de assuntos que eu pretendo falar no futuro por aqui no Medium, mas em artigos de assuntos mais específicos.

Mãos à Obra!

Até agora tudo está muito fácil. Estava.

Importação de dados

A melhor forma de começar o exemplo é importando dados para o Neo4j trabalhar. E pensando nisso, eles criaram o comando LOAD CSV que é muito poderoso e permite que se configure os nós de acordo com as regras que quisermos. É possível inserir só itens novos, é possível criar relações novas durante a importação, é possível alterar nós já existentes - este comando é muito flexível porque pode ser usado em conjunto com MERGE e CREATE.

Para importar os dados eu montei 2 arquivos:

  • Alunos.csv: contém a lista de alunos para podermos trabalhar
  • Disciplinas.csv: contém a lista de cursos, pré-requisitos, número de créditos, ementas e etc.

Para criar os arquivos é recomendável o uso do Notepad++. E é obrigatório o uso formato UTF-8 para fazer a importação. O Notepad++ permite configurar isso de forma fácil. Note nas imagens abaixo que não pode haver espaço entre as vírgulas ou barras verticais. Se isso acontecer, você pode ter que limpar os espaços em branco com a função trim() em cada campo. Como eu sou o responsável por gerar o arquivo, optei por gerar o arquivo adequadamente.

Lista de alunos separados por vírgula, sem espaços antes ou depois das vírgulas. Note no canto inferior direito que o formato ativo é UTF-8.
Note a estrutura de dados que usei para agrupar os pré-requisitos. O curso de Física III tem 3 conjuntos possíveis de pré-requisitos. Isto precisa ser refletido no modelo!

Com os arquivos em mãos, importei os mesmos usando os seguintes comandos:

//carregar csv com os alunos
load csv with headers from ‘file:///D:/alunos.csv’ as l
create(:Aluno {RA: l.RA, Nome: l.Nome, Email: l.Email, SemestreIngresso: l.SemestreIngresso})
//carregar csv com as disciplinas
load csv with headers from ‘file:///D:/disciplinas.csv’ as l fieldterminator ‘|’
create(:Disciplina {Sigla: l.Sigla, Nome: l.Nome, Creditos: toInt(l.Creditos), Prereq: l.Prereq, Ementa: l.Ementa})

É possível mudar o delimitador de campos e é possível também aplicar funções nativas do Neo4j como toInt, toString, trim, etc. Neste caso estou usando o CREATE para efetivamente criar os nodes ao importar linha por linha. E também, aproveito para definir um label para cada aluno e disciplina criados.

Neste momento temos apenas alguns nós soltos na base, sem qualquer tipo de relação entre eles:

Comandos de importação executados com sucesso, mostrando o que foi feito. É importante criar o hábito de conferir as contagens e validar se aconteceu o que se esperava.
Esta representação gráfica é bacana para mostrar como ficaram os nós na base. Note que eu customizei a apresentação visual do nó colocando o RA (registro acadêmico = ID do Aluno) e a sigla da disciplina. Para editar, é preciso alterar um arquivo .grass que existe nas opções avançadas da ferramenta - é como editar um CSS.

Precisamos conectar nosso nós de uma forma lógica.

Montando as turmas do primeiro semestre de 2014

Como o aluno não pode escolher quais matérias terá que cursar ao entrar na faculdade, todos começam com um set padrão de disciplinas. Além disso, configurei algumas propriedades na relação “Cursou” para guardar as notas finais, presença e eventual trancamento.

Para fazer a relação eu faço uma sequência MATCH e MERGE. Seleciono quais alunos e cursos me interessam no primeiro MATCH e exponho a seleção usando as variáveis “a” e “d” para o próximo comando MERGE. Este MERGE verifica se há uma relação com este nome entre os nós e se não houver ele cria uma nova relação. E também é possível escolher o que fazer quando encontrar e quando não encontrar uma relação existente. Na verdade dá para usar o MERGE de formas bem complexas, mas a ideia é tentar manter a simplicidade sempre que possível. Note que se o MATCH retornar várias linhas, o processamento ocorrerá para cada linha. Ou seja, sempre há uma iteração do resultado.

Para criar as turmas de uma só vez, usei um truque com o WITH. Normalmente só é possível executar um única estrutura MATCH e MERGE por vez. Para executar mais comandos de uma vez, usa-se o comando WITH que serve para guardar o resultado de uma consulta anterior para usar na consulta posterior. Com isso, eu posso criar vários grupos de comandos sem qualquer relação entre si, separados por um WITH. E nisso, é possível o efeito “batch” para a operação. Veja só:

//montar as turmas dos ingressantes de 2014match(a:Aluno {SemestreIngresso: “2014S1”}),(d:Disciplina {Sigla: “F 128”} )
merge (a)-[r: Cursou {Semestre: “2014S1”, NotaFinal: 7.5, Presença: 99.57, Trancou: false }]-(d)
with count(*) as affected
match(a:Aluno {SemestreIngresso: “2014S1”}),(d:Disciplina {Sigla: “F 129”} )
merge (a)-[r: Cursou {Semestre: “2014S1”, NotaFinal: 8.5, Presença: 98.57, Trancou: false }]-(d)
with count(*) as affected
match(a:Aluno {SemestreIngresso: “2014S1”}),(d:Disciplina {Sigla: “FM003”} )
merge (a)-[r: Cursou {Semestre: “2014S1”, NotaFinal: 9.5, Presença: 97.97, Trancou: false }]-(d)
with count(*) as affected
match(a:Aluno {SemestreIngresso: “2014S1”}),(d:Disciplina {Sigla: “MA111”} )
merge (a)-[r: Cursou {Semestre: “2014S1”, NotaFinal: 7.2, Presença: 97.97, Trancou: false }]-(d)
with count(*) as affected
match(a:Aluno {SemestreIngresso: “2014S1”}),(d:Disciplina {Sigla: “MA141”} )
merge (a)-[r: Cursou {Semestre: “2014S1”, NotaFinal: 8.1, Presença: 95.12, Trancou: false }]-(d)
return *

Ao executar o comando, o editor avisa que pode haver um risco de termos um produto cartesiano na consulta, que seria o cruzamento de todos os nós da consulta. No caso isto é intencional. E o resultado fica bem bacana visualmente:

A ferramenta avisa que uma consulta pode ser muito custosa para o banco. No caso, é o que precisa ser feito.
Resultado do comando batch, mostrando a última turma criada. Os vinte alunos de 2014 forçosamente tiveram que cursar todas as matérias do primeiro semestre.
Consulta pedindo todos os nós que estejam ligados por qualquer tipo de relação. Como só há a relação “Cursou”, a query responde com todas as turmas que acabamos de criar e todos os alunos associados às turmas recém-criada. Podemos ver aqui os 20 anos matriculados em 5 disciplinas cada um.

Foram associados alunos com turmas de uma forma genérica, com todos tirando notas boas, sem trancamentos e nem faltas. Para tornar o exemplo mais realista, vamos colocar alguns trancamentos e reprovações na base.

// modificar turmas criadas: 5 trancamentos
match(a:Aluno)-[r:Cursou]->(d:Disciplina)
with r
limit 5
set r.Trancou = true, r.NotaFinal = NULL, r.Presença = 0.0
// modificar turmas criadas: 30 reprovações aleatórias
match(a:Aluno)-[r:Cursou]->(d:Disciplina)
where r.NotaFinal>= 6.0
with r
limit 30
set r.NotaFinal = r.NotaFinal - 5

Aqui não há nada de complicado. Mas note novamente que faço um MATCH de todos os alunos que cursaram disciplinas. Com o uso do WITH, exponho na variável/alias “r” todos os relacionamentos encontrados para a próxima parte da query. Note que há também a cláusula LIMIT, que limita a quantidade de registros retornados (não encontrei nenhuma forma de passar uma porcentagem como no SQL Server). A última linha de cada consulta usa a variável “r” e através do comando SET eu consigo alterar as propriedades do nós encontrados.

Montando a árvore de pré-requisitos

Agora as coisas vão começar a complicar… até o momento tudo estava muito fácil. Tudo até aqui não passou de simples manipulação de dados.

Lembra que o pré-requisito está marcado em cada disciplina? Conforme mostrei em uma das figuras acima, os pré-requisitos aparecem em grupos e para isso, é preciso quebrar estes grupos e criar os nós e relações de acordo.

Por exemplo, Cálculo II tem estes pré-requisitos: MA111,MA141;MA151,MA141. O algoritmo mais óbvio para gravar estes nós seria assim:

  1. Verificar de qual matéria estamos falando: MA211 (Cálculo II)
  2. Ler os pré-requisitos: MA111,MA141;MA151,MA141.
  3. Separar os grupos (MA111 e MA141 ficam juntos) e (MA151 e MA141 ficam juntos).
  4. Criar um nó para cada grupo (MA211 terá 2 nós de pré-requisitos)
  5. Procurar a disciplina e associar aos grupos caso elas existam (MA211 terá 2 nós com 2 disciplinas existentes em cada nó). Ou seja, MA211 vai ter 2 pré-requisitos representados como nós, sendo que o primeiro nó vai ter as matérias MA111 / MA141 e no segundo nó, vai ter as matérias MA151 / MA141.

Parece um modelo complicado e meio chato de modelar. No mundo relacional também seria complicado modelar isto. Mas a grande verdade é que no Cypher tudo é muito mais simples porque há um suporte mais moderno para operações com listas, coleções e conjuntos de dados.

A query para fazer a montagem da árvore de pré-requisitos é essa. Comparando com linguagens normais de programação, é como se estivéssemos fazendo um for dentro de um for:

//encontrar as matérias que tenham pré-requisitos
match(d:Disciplina) where d.Prereq is not null
//ler a disciplina e seus prereq - quebrar os grupos de prereqs
with d,split(d.Prereq, “;”) as grupoPrereq
//processar cada subgrupo de preqs como se fosse um for
unwind(grupoPrereq) as g
//ler cada disciplina deste grupo de prereqs
with split(g, “,”) as paresPrereq, d
//como se fosse um outro for dentro do for, processar cada disciplina
unwind (paresPrereq) as disciplinaDestePrereq
//procurar a disciplina de destino
match (d2:Disciplina {Sigla: disciplinaDestePrereq})
//criar relação possui e composto_de quando onde não houver
merge (d)-[r:Possui]->(pr:Prereq{Siglas: paresPrereq})
merge (pr:Prereq{Siglas: paresPrereq})-[r2:Composto_de]->(d2)

Primeiramente eu faço um MATCH para encontrar quais disciplinas possuem pré-requisitos. Depois eu uso WITH para passar a disciplina e uso uma função interna chamada split que separa a string dos pré-requisitos usando o ponto-e-vírgula como separador. Crio uma variável chamada grupoPrereq para poder usar abaixo.

O grande segredo aqui é o uso do comando UNWIND. Ele serve para transformar uma coleção em linhas. Este comando é brutalmente útil para fazer transformações mais complexas. Por exemplo, quando eu fiz o split, a variável grupoPrereq recebeu uma coleção de valores. Seria algo como [“MA111,MA141” , “MA151,MA141”]. Esta coleção é iterada pelo UNWIND, fazendo com que eu possa olhar um grupo de cada vez. Então ele vai processar o grupo “MA111,MA141” e depois o grupo “MA151,MA141”. Como estes grupos também são separados por vírgula, compensa usar o UNWIND de novo para fazer a leitura de cada disciplina. O efeito do UNWIND é similar ao efeito de um for em uma outra linguagem de programação. Link para a documentação do UNWIND: http://neo4j.com/docs/stable/query-unwind.html . Vale notar também que há o comando FOREACH mas na prática ele só serve para modificar um conjunto de dados selecionado anteriormente por um MATCH.

Já de posse da disciplina que estou processando os pré-requisitos e também já de posse do pré-requisito a ser processado, consigo fazer um MATCH para buscar a disciplina de destino. Eu montei a lista de disciplinas para sempre encontrar os pré-requisitos e portanto, não há validação se eu encontrei ou não o pré-requisito. Uso o MERGE para montar a árvore de pré-requisitos duas vezes: na primeira eu criou um subgrafo dizendo que a matéria possui um pré-requisito e no segundo MERGE, crio um outro subgrafo dizendo quais são os nós do tipo Disciplina que compõem este pré-requisito recém-criado. A ideia final é criar uma uma algo como (disciplina)-[possui]-(pre-requisito)-[composto de]->(disciplina).

Observando o grafo para o MA211, temos a seguinte estrutura após rodar esta query:

A disciplina MA211 possui dois grupos de pré-requisitos: MA111 e MA141 e também, MA151 e MA141.

Escolhi usar este modelo de dados pouco mais complexo por alguns motivos. Na vida real, a disciplina MA141 é um pré-requisito parcial (se você reprovar com 4.0, você pode ingressar na MA211). Logo, a relação “Composto de” pode ter propriedades que façam sentido só para ele. A relação “Possui”, na vida real, pode ter uma validade como por exemplo, “válido de 2005 a 2010”. Ou seja, também é possível haver parâmetros nesta relação entre curso e pré-requisito.

Provavelmente você irá se deparar com várias decisões de projeto como por exemplo, “esta informação é um relacionamento ou um nó?”. Eu pessoalmente acho que o nosso melhor amigo é uma folha em branco ou uma lousa, e convém desenhar o grafo das duas opções para sentir qual é a melhor forma de trabalhar. E claro, como estamos trabalhando apenas com nós e relações, é relativamente fácil mudar de opinião e corrigir o design do modelo de dados. Ao projetar este pedaço do sistema, rabisquei algumas vezes no papel até chegar neste modelo.

Utilizando um banco de dados relacional este modelo seria parecido, mas provavelmente, usaria um modelo mais complexo de tabelas e forçaria os desenvolvedores a usar vários joins complicados. Usando um modelo relacional para isso, o entendimento do modelo por outras pessoas ficaria seriamente comprometido. A ferramenta que o Neo4j disponibiliza permite que o grafo seja visualizado de forma muito clara, como pudemos ver no grafo dos pré-requisitos da disciplina MA211.

Como eu gosto muito destes grafos gerados pela ferramenta, eu exportei o grafo completo de pré-requisitos usando a query abaixo:

match (d:Disciplina)-[r:Possui]-(a:Prereq)-[r2:Composto_de]-(d2:Disciplina) return d,r,a,r2,d2

O resultado é relativamente grande e por isso a ferramenta permite fazer a exportação do grafo para formatos convenientes como svg, png, csv e json. Exportando para png temos:

Grafo de pré-requisitos de todas as matérias cadastradas na base para este artigo.

Acho que fica relativamente complexo analisar tudo de uma vez, mas a imagem em si é bem bacana :)

Validando se o aluno possui pré-requisitos para os cursos em que deseja se matricular

Por fim, para todo este trabalho ter uma finalidade útil, precisamos saber se o aluno pode ou não se matricular nos cursos que deseja. Para isso, o sistema precisa comparar as matérias cursadas com as disciplinas da lista de pré-requisitos.

A query para resolver este problema é a seguinte:

//para cara curso que o aluno pediu para se matricular…
unwind [“MA211”, “F 228”, “F 229”, “MA311”] as cursoParaMatricular
//ler o grafo de pré-requisitos
match (d1:Disciplina)-[c:Composto_de]-(p:Prereq)-[ps:Possui]-(d1b:Disciplina{Sigla: cursoParaMatricular})
//..e iterar sobre os grupos de prereqs
with p, cursoParaMatricular
//cruzar com os cursos terminados com sucesso, iterando pelo parâmetro p
match (a:Aluno {RA: “140010”})-[r:Cursou{Semestre: “2014S1”}]->(d)-[c:Composto_de]-(p)
where r.NotaFinal >= 5 and r.Trancou = false
//preparar as partes do resultado que interessam: o aluno, uma lista de disciplinas cursadas com sucesso, o pré-requisito sendo analisado e o curso que o aluno deseja se matricular
with distinct a.RA as RA, collect(distinct d.Sigla) as cursosAprovados, p, cursoParaMatricular
//usar a mesma lista acima, mas computando já quais são os pré-requisitos atingidos comparando o que o aluno fez com o que o aluno deveria ter feito. Mantenho as outras variáveis do relatório neste with para serem exibidas no return.
with RA, all(x in p.Siglas where x in cursosAprovados) as habilitado, p.Siglas as prereqs, cursosAprovados, cursoParaMatricular
where habilitado = true
//retorno de tudo o que foi feito para ser mostrado de forma tabular e simples
return RA, habilitado, cursosAprovados, prereqs, cursoParaMatricular

A mesma query em cores (para facilitar a leitura:)

Relatório para trazer os pré-requisitos alcançados

Como resposta para esta consulta, podemos ver que o aluno com RA 140010 foi bem em todas as matérias e pode cursar quase todas do segundo semestre. Só não pode cursar MA311 porque ainda não cursou MA211.

Relatório com os detalhes dos cursos realizados para alcançar os pré-requisitos dos cursos solicitados.

Para efeito de testes, modifiquei a nota de F 128 dele para ficar abaixo de cinco. Nisso ele perderá alguns pré-requisitos:

match (a:Aluno {RA: “140010”})-[r:Cursou{Semestre: “2014S1”}]->(d:Disciplina{Sigla: “F 128”})
set r.NotaFinal = 4.5

Rodando a mesma consulta acima, agora os pré-requisitos alcançados são poucos…

Após a redução da nota, o aluno agora só pode cursar Cálculo II. Logo, é importante ele estudar bastante certas matérias, pois elas trancam grande parte do curso!

Para montar este relatório usei funções bacanas e bem comuns como o collect, responsável por pegar o que está em várias linhas e transformar em um vetor (array) de elementos. Em outras linguagens de programação seria equivalente a adicionar todos itens que retornam em uma lista. No caso, eu usei o collect para guardar todos os resultados trazidos pelo MATCH em uma variável chamada “cursosAprovados”. Ou seja, como você pode ver, se o MATCH retornar várias linhas, é possível agrupá-las como desejar, seja usando somas e contagens, ou então, com operações como o collect. Para entender mais como trabalhar com estruturas de dados, recomendo fortemente a leitura desta documentação: http://neo4j.com/docs/stable/cypherdoc-utilizing-data-structures.html

Outra função bacana que o Neo4j já nos traz por padrão são funções que operam em coleções. Eu usei a função all. Ela serve para verificar se uma condição informada dentro do parênteses é verdade para todos os itens da coleção. Eu uso isso para verificar se todos os pré-requisitos estão dentro da lista de cursos aprovados. Se sim, eu considero o aluno habilitado. Eu filtro posteriormente o retorno para mostrar só alunos habilitados, porque pode acontecer que o aluno consiga o pré-requisito para um conjunto de matérias e para outro conjunto ele não consiga, o que é normal - os pré-requisitos são feitos para atender a alunos que venham de vários cursos da Unicamp e não só para os alunos do curso de Física. Isso é visível na matéria MA211. O aluno pode cursar MA111 (Cálculo I com 6 créditos) ou MA151 (Cálculo I com 4 créditos). Eu tiro do retorno as linhas que mostram que ele não cursou MA151 (que é o Cálculo para outros cursos, um pouco mais fácil hehehe).

Alguém se arrisca a mostrar no mesmo relatório os detalhes dos pré-requisitos não alcançados também?

Conclusão

Depois dessa longa demonstração de como as coisas funcionam no Neo4j, recomendo apenas uma coisa: que você tente brincar com o Neo4j tentando modelar e resolver um problema que você já conheça bem.

Tente comparar os esforços que seriam necessários para modelar o mesmo problema usando um banco de dados relacional e talvez, um banco de dados orientado a documentos.

Pessoalmente, pretendo usar cada vez mais este modelo orientado a grafos em meus projetos, já que o mercado (pelo menos o brasileiro!) não está pronto para absorver este tipo de tecnologia. O que mais vejo é um grande conservadorismo nas decisões a uma má vontade generalizada em procurar formas melhores de resolver problemas. Mas, se por acaso, você tiver uma equipe afim de ir atrás, afim de progredir e afim de testar uma coisa nova, recomendo usar esta tecnologia em um microserviço ou projeto menor para sentir os efeitos desta tecnologia na arquitetura do projeto.

Vale notar que existe um novo paradigma, que é o mapeamento de classes para grafos ao invés de tabelas. Este paradigma se chama OGM (Object-Graph Mapping). Em Java já há uma biblioteca (http://neo4j.com/blog/neo4j-java-object-graph-mapper-released e https://github.com/neo4j/neo4j-ogm) que faz isso, mas para .NET ainda não vi isso. Como relações de classes também podem ser vistas como um grafo, faz muito mais sentido mapear classes e suas relações como grafos do que como tabelas. Imagino que em um futuro de alguns anos, o padrão, que é escolher um banco de dados relacional para tudo o que é novo possa até mudar para um banco orientado a grafos. As vantagens no design, na facilidade de consulta, flexibilidade de mudanças e facilidade de comunicar o modelo para todos não podem ser mais ignoradas. E já muita gente usando esta tecnologia no dia-a-dia e cada vez mais parece haver momentum para o uso de graph databases. Acredito que cada vez mais os arquitetos olharão com carinho para eles.

Em um próximo post vou buscar explorar como este domínio apresentado aqui pode ser equacionado usando o DDD, conectando este modelo que criamos hoje a uma aplicação .NET.

Um grande abraço a todos e espero ter sido útil.

Caso queira falar mais sobre isso meu e-mail: mariomeyrelles@gmail.com :)

--

--

Mário Meyrelles
Accendis Tech

Experienced developer. I like F#, Scala, Azure, CQRS, Event Sourcing and microservices. I also like to talk about other non-technical things.