Ir al contenido
learn

1. Un primer grafo

LadybugDB es una biblioteca que se ejecuta dentro del proceso que la usa. La CLI de LadybugDB, lbug, es uno de esos procesos: un único ejecutable. Este curso la usa hasta la lección 5, y fija la versión 0.20.4, la que se usó para capturar todas las salidas.

Ventana de terminal
curl.exe -L -O https://github.com/LadybugDB/ladybug/releases/download/v0.20.4/lbug_cli-windows-x86_64.zip
Expand-Archive lbug_cli-windows-x86_64.zip -DestinationPath lbug
.\lbug\lbug.exe --version

El archivo zip solo contiene lbug.exe (14,8 MB). Necesita dos DLL de OpenSSL que no incluye, libssl-3-x64.dll y libcrypto-3-x64.dll: sin ellas, termina con el código 0xC0000135 y ningún mensaje. En mi máquina, vienen de Git for Windows, C:\Program Files\Git\mingw64\bin, en el PATH de Git Bash. Añade su carpeta al PATH para escribir lbug desde cualquier lugar. La página de instalación usa releases/latest/download/ en lugar de un número de versión: siempre la release más reciente.

Lbug 0.20.4

Clona el repositorio de este sitio y entra en la carpeta del curso, donde funcionan las rutas relativas de todas las consultas de este curso:

Ventana de terminal
git clone https://github.com/spareilleux/learn.git
cd learn/code/ladybugdb

Esta lección todavía no lee ningún archivo: construye a mano un grafo de tres lecciones del curso de DuckDB. La lección 2 carga el sitio entero.

Inicia la CLI con lbug, sin argumentos:

Opening the database under in-memory mode.
Enter ":help" for usage hints.

Luego escribe una consulta, que termina en punto y coma como en SQL:

RETURN 1 + 1 AS two;
┌───────┐
│ two │
│ INT64 │
├───────┤
│ 2 │
└───────┘
(1 tuple)
(1 column)
Time: 8.61ms (compiling), 0.72ms (executing)

El formato de salida es box, el predeterminado de la CLI, con el tipo de cada columna bajo su nombre. Las salidas del resto del curso omiten las tres últimas líneas, las estadísticas de la consulta. :quit sale de la CLI; :help enumera los demás comandos, que empiezan por dos puntos y no son Cypher.

La base de datos vive en memoria y desaparece con la CLI. La lección 8 vuelve sobre los archivos de base de datos; por ahora, cada script vuelve a crear las tablas.

En Neo4j, un nodo recibe su etiqueta y sus propiedades cuando lo creas. LadybugDB, como SQL, quiere primero las tablas (CREATE NODE TABLE, CREATE REL TABLE). Las primeras sentencias de cypher/01-first-graph.cypher:

CREATE NODE TABLE Course(slug STRING PRIMARY KEY, title STRING);
CREATE NODE TABLE Lesson(url STRING PRIMARY KEY, number INT64, title STRING);
CREATE REL TABLE HAS_LESSON(FROM Course TO Lesson);
CREATE REL TABLE LINKS_TO(FROM Lesson TO Lesson);
┌────────────────────────────────┐
│ result │
│ STRING │
├────────────────────────────────┤
│ Table Course has been created. │
└────────────────────────────────┘

(y el mismo mensaje para las otras tres tablas)

Una tabla de nodos es una tabla con clave primaria, que LadybugDB exige. Una tabla de relaciones es la tabla intermedia que escribirías en SQL, HAS_LESSON(course_slug, lesson_url) con dos claves foráneas, salvo que nunca nombras las claves: basta con FROM Course TO Lesson, y cada relación apunta a los dos nodos mismos. Una tabla de relaciones también puede tener sus propias columnas (la lección 2 le da a LINKS_TO un anchor).

Los nombres siguen las convenciones habituales de Cypher: PascalCase para las tablas de nodos, UPPER_SNAKE_CASE para las tablas de relaciones. LadybugDB no las impone.

CREATE recibe un patrón. Un nodo se escribe entre paréntesis, con su tabla tras dos puntos y sus propiedades entre llaves (líneas 11-18):

CREATE (:Course {slug: 'duckdb', title: 'DuckDB'});
CREATE (:Lesson {url: '/duckdb/01-first-queries/', number: 1, title: 'First queries'}),
(:Lesson {url: '/duckdb/02-friendly-sql/', number: 2, title: 'Friendly SQL'}),
(:Lesson {url: '/duckdb/03-nested-data/', number: 3, title: 'Nested data'});
MATCH (c:Course {slug: 'duckdb'}), (l:Lesson) CREATE (c)-[:HAS_LESSON]->(l);
MATCH (a:Lesson {number: 2}), (b:Lesson {number: 1}) CREATE (a)-[:LINKS_TO]->(b);

Una relación conecta dos nodos existentes, así que las dos últimas sentencias primero los encuentran con MATCH y luego crean la flecha entre las variables c y l. (c:Course {slug: 'duckdb'}), (l:Lesson) es una combinación cruzada: un curso por tres lecciones, tres relaciones HAS_LESSON. La flecha da la dirección: la lección 2 enlaza a la lección 1.

Estas sentencias no devuelven filas, así que la CLI solo imprime las estadísticas.

MATCH encuentra cada lugar del grafo donde encaja un patrón, y RETURN es el SELECT (líneas 21-26):

MATCH (c:Course)-[:HAS_LESSON]->(l:Lesson)
RETURN c.title, l.number, l.title
ORDER BY l.number;
MATCH (a:Lesson)-[:LINKS_TO]->(b:Lesson)
RETURN a.title AS from_lesson, b.title AS to_lesson;
┌─────────┬──────────┬───────────────┐
│ c.title │ l.number │ l.title │
│ STRING │ INT64 │ STRING │
├─────────┼──────────┼───────────────┤
│ DuckDB │ 1 │ First queries │
│ DuckDB │ 2 │ Friendly SQL │
│ DuckDB │ 3 │ Nested data │
└─────────┴──────────┴───────────────┘
┌──────────────┬───────────────┐
│ from_lesson │ to_lesson │
│ STRING │ STRING │
├──────────────┼───────────────┤
│ Friendly SQL │ First queries │
└──────────────┴───────────────┘

La misma consulta en SQL, con la tabla intermedia: SELECT c.title, l.number, l.title FROM Course c JOIN HAS_LESSON h ON h.course_slug = c.slug JOIN Lesson l ON l.url = h.lesson_url ORDER BY l.number. El patrón dice lo mismo sin las condiciones de join.

Las lecciones a las que no enlaza ninguna lección necesitan un patrón que no existe, una subconsulta EXISTS (líneas 29-32):

MATCH (l:Lesson)
WHERE NOT EXISTS { MATCH (l)<-[:LINKS_TO]-(:Lesson) }
RETURN l.title
ORDER BY l.title;
┌──────────────┐
│ l.title │
│ STRING │
├──────────────┤
│ Friendly SQL │
│ Nested data │
└──────────────┘

En un patrón, la flecha puede apuntar en cualquier dirección: (l)<-[:LINKS_TO]-(:Lesson) es un enlace hacia l. «First queries» es la única lección con un enlace entrante.

Tres errores (líneas 35-37): una clave duplicada, una propiedad que la tabla no tiene, un nodo sin su clave.

CREATE (:Lesson {url: '/duckdb/01-first-queries/', number: 1, title: 'Again'});
CREATE (:Lesson {url: '/duckdb/04-files/', minutes: 30});
CREATE (:Lesson {number: 4});
Error: Runtime exception: Found duplicated primary key value /duckdb/01-first-queries/, which violates the uniqueness constraint of the primary key column.
Error: Binder exception: Cannot find property minutes for .
Error: Binder exception: Create node expects primary key url as input.

Una Binder exception significa que la consulta se analizó bien, pero que un nombre o una propiedad no encaja con el esquema: la sentencia se rechaza antes de ejecutarse. Una Runtime exception ocurre durante la ejecución, aquí cuando el índice de la clave primaria encuentra el valor. Los nombres vacíos de los dos últimos mensajes («for .», «node expects») son la variable que el nodo no tiene: los mensajes se construyen con ella (bind_property_expression.cpp, bind_updating_clause.cpp). Con CREATE (l:Lesson {number: 4}), el mensaje dice Create node l expects….

MERGE encuentra un patrón, o lo crea si nada coincide. ON CREATE SET y ON MATCH SET indican qué hacer en cada caso, como las ramas WHEN NOT MATCHED y WHEN MATCHED del MERGE de SQL Server (líneas 40-41):

MERGE (l:Lesson {url: '/duckdb/04-files/'}) ON CREATE SET l.number = 4, l.title = 'Files' RETURN l.*;
MERGE (l:Lesson {url: '/duckdb/04-files/'}) ON MATCH SET l.title = 'Files: CSV and Parquet' RETURN l.*;
┌───────────────────┬──────────┬─────────┐
│ l.url │ l.number │ l.title │
│ STRING │ INT64 │ STRING │
├───────────────────┼──────────┼─────────┤
│ /duckdb/04-files/ │ 4 │ Files │
└───────────────────┴──────────┴─────────┘
┌───────────────────┬──────────┬────────────────────────┐
│ l.url │ l.number │ l.title │
│ STRING │ INT64 │ STRING │
├───────────────────┼──────────┼────────────────────────┤
│ /duckdb/04-files/ │ 4 │ Files: CSV and Parquet │
└───────────────────┴──────────┴────────────────────────┘

l.* se expande a todas las propiedades del nodo.

MERGE busca el patrón entero, no solo la clave. Dos formas de equivocarse (líneas 44-45):

MERGE (l:Lesson {number: 4}) RETURN l.*;
MERGE (l:Lesson {url: '/duckdb/04-files/', number: 5}) RETURN l.*;
Error: Binder exception: Create node l expects primary key url as input.
Error: Runtime exception: Found duplicated primary key value /duckdb/04-files/, which violates the uniqueness constraint of the primary key column.

El primer patrón no tiene clave, así que MERGE no podría crear el nodo si nada coincidiera: el binder lo rechaza, aunque la lección 4 exista. El segundo patrón tiene la clave, pero la lección 4 tiene number 4, no 5: nada coincide, MERGE intenta crear un segundo /duckdb/04-files/, y la clave primaria lo impide. Pon la clave sola en el patrón, y las demás propiedades en ON CREATE SET u ON MATCH SET.

Los comandos SHOW … de Neo4j son funciones en LadybugDB, que se llaman con CALL (líneas 48-51, sin el :schema de la línea 50):

CALL show_tables() RETURN name, type ORDER BY name;
CALL table_info('Lesson') RETURN *;
MATCH (n) RETURN label(n) AS table_name, count(*) AS nodes ORDER BY table_name;
┌────────────┬────────┐
│ name │ type │
│ STRING │ STRING │
├────────────┼────────┤
│ Course │ NODE │
│ HAS_LESSON │ REL │
│ LINKS_TO │ REL │
│ Lesson │ NODE │
└────────────┴────────┘
┌─────────────┬────────┬────────┬────────────────────┬─────────────┐
│ property id │ name │ type │ default expression │ primary key │
│ INT32 │ STRING │ STRING │ STRING │ BOOL │
├─────────────┼────────┼────────┼────────────────────┼─────────────┤
│ 0 │ url │ STRING │ NULL │ True │
│ 1 │ number │ INT64 │ NULL │ False │
│ 2 │ title │ STRING │ NULL │ False │
└─────────────┴────────┴────────┴────────────────────┴─────────────┘
┌────────────┬───────┐
│ table_name │ nodes │
│ STRING │ INT64 │
├────────────┼───────┤
│ Course │ 1 │
│ Lesson │ 4 │
└────────────┴───────┘

(n) sin tabla coincide con los nodos de todas las tablas de nodos. label(n) devuelve la tabla de cada uno, y count(*) agrupa por las demás columnas del RETURN: Cypher no tiene GROUP BY.

El comando :schema de la CLI, en la línea 50 del mismo script, imprime el esquema como las sentencias que lo volverían a crear:

CREATE NODE TABLE `Course` (`slug` STRING,`title` STRING, PRIMARY KEY(`slug`));
CREATE NODE TABLE `Lesson` (`url` STRING,`number` INT64,`title` STRING, PRIMARY KEY(`url`));
CREATE REL TABLE `HAS_LESSON` (FROM `Course` TO `Lesson`, MANY_MANY);
CREATE REL TABLE `LINKS_TO` (FROM `Lesson` TO `Lesson`, MANY_MANY);

MANY_MANY es la multiplicidad predeterminada de una tabla de relaciones: un curso puede tener muchas lecciones, y una lección podría pertenecer a muchos cursos. ONE_MANY, MANY_ONE y ONE_ONE la restringen (CREATE REL TABLE).

Un nodo con relaciones no se puede eliminar solo (DELETE, líneas 54-57):

MATCH (l:Lesson {number: 3}) DELETE l;
MATCH (l:Lesson {number: 3}) DETACH DELETE l;
MATCH (l:Lesson) RETURN count(*) AS lessons;
MATCH ()-[r]->() RETURN label(r) AS table_name, count(*) AS relationships ORDER BY table_name;
Error: Runtime exception: Node(nodeOffset: 2) has connected edges in table HAS_LESSON in the bwd direction, which cannot be deleted. Please delete the edges first or try DETACH DELETE.
┌─────────┐
│ lessons │
│ INT64 │
├─────────┤
│ 3 │
└─────────┘
┌────────────┬───────────────┐
│ table_name │ relationships │
│ STRING │ INT64 │
├────────────┼───────────────┤
│ HAS_LESSON │ 2 │
│ LINKS_TO │ 1 │
└────────────┴───────────────┘

Como una clave foránea sin ON DELETE CASCADE: el curso sigue apuntando a la lección 3. «bwd direction» significa que la relación llega al nodo (backward); nodeOffset: 2 es la posición del nodo en su tabla, la tercera lección creada. DETACH DELETE elimina el nodo y sus relaciones, así que HAS_LESSON pasa de 3 a 2.

La lección 3 se eligió a propósito. La lección 1 tiene dos tipos de relaciones, HAS_LESSON desde el curso y LINKS_TO desde la lección 2, y el error solo nombra la primera tabla que LadybugDB comprueba: HAS_LESSON en Windows y macOS, LINKS_TO en Linux, en la CI de este curso. El código se detiene en la primera tabla de relaciones que tiene alguna.

Scripts y salida CSV: cómo se prueba este curso

Sección titulada «Scripts y salida CSV: cómo se prueba este curso»

La CLI también lee consultas de la entrada estándar. check.sh ejecuta cada script de cypher/ con tres opciones, --no_progress_bar, --no_stats y --mode csv, y compara la salida con el archivo del mismo nombre en cypher/expected/:

Ventana de terminal
Get-Content cypher/01-first-graph.cypher | lbug --no_progress_bar --no_stats --mode csv
result
Table Course has been created.
…
c.title,l.number,l.title
DuckDB,1,First queries
DuckDB,2,Friendly SQL
DuckDB,3,Nested data
…
Error: Runtime exception: Found duplicated primary key value /duckdb/01-first-queries/, which violates the uniqueness constraint of the primary key column.

Tres cosas que conviene saber de este modo:

  • Los errores van a la salida estándar, y el código de salida sigue siendo 0. La CLI imprime el mensaje y pasa a la siguiente sentencia. Un script no puede confiar en $? ni en $LASTEXITCODE para detectar una consulta fallida; la salida comparada sí puede, y por eso los archivos esperados contienen los errores.
  • Sin --no_progress_bar, la salida contiene códigos de escape de terminal, los de la barra de progreso que la CLI activa salvo que pases la opción, incluso cuando la salida no es una terminal.
  • Una sentencia que no devuelve nada imprime una línea vacía en modo CSV, y nada en modo box.

En un runner de GitHub, para cada uno de los tres sistemas operativos: ok 01-first-graph.

  • LadybugDB es una biblioteca que se ejecuta dentro de tu proceso; la CLI lbug es un único ejecutable y no necesita ningún servidor.
  • Primero las tablas: tablas de nodos con clave primaria, tablas de relaciones FROM una tabla de nodos TO otra.
  • CREATE crea un patrón, MATCH encuentra uno, MERGE hace cualquiera de las dos cosas; una relación se crea entre nodos que un MATCH encontró.
  • Un patrón sustituye a los joins; NOT EXISTS { MATCH … } encuentra lo que no está conectado.
  • Los errores indican su tipo (Binder, Runtime), pero el código de salida de la CLI es 0 incluso cuando una consulta falla.

Las soluciones están en cypher/01-exercises.cypher, comprobadas por la CI igual que la lección. Parten del mismo esquema, con las lecciones 1 y 2 y sus relaciones HAS_LESSON.

  1. Escribe una sola sentencia que añada la lección 5, /duckdb/05-csharp/ «DuckDB from C#», y la conecte con el curso. Ejecútala dos veces: ¿cuántas lecciones tiene el curso?
Solución

De 01-exercises.cypher, líneas 16-19:

MATCH (c:Course {slug: 'duckdb'})
MERGE (l:Lesson {url: '/duckdb/05-csharp/'}) ON CREATE SET l.number = 5, l.title = 'DuckDB from C#'
MERGE (c)-[:HAS_LESSON]->(l);
MATCH (c:Course)-[:HAS_LESSON]->(l:Lesson) RETURN c.slug, count(*) AS lessons;
┌────────┬─────────┐
│ c.slug │ lessons │
│ STRING │ INT64 │
├────────┼─────────┤
│ duckdb │ 3 │
└────────┴─────────┘

Tres, incluso después de dos ejecuciones. La segunda vez, el primer MERGE encuentra la lección por su clave; el segundo MERGE encuentra la relación entre c y l y no crea nada. Una sentencia puede encadenar cláusulas: MATCH, luego MERGE, luego MERGE, cada una trabajando sobre las filas de la anterior.

  1. ¿Qué pasa si creas una relación LINKS_TO de la lección 2 a una lección 9 que no existe?
Solución

De 01-exercises.cypher, líneas 22-23:

MATCH (a:Lesson {number: 2}), (b:Lesson {number: 9}) CREATE (a)-[:LINKS_TO]->(b);
MATCH ()-[r:LINKS_TO]->() RETURN count(*) AS links;
┌───────┐
│ links │
│ INT64 │
├───────┤
│ 0 │
└───────┘

Nada, y sin error. MATCH no encuentra ninguna lección 9, así que la combinación cruzada no tiene filas, y CREATE se ejecuta cero veces. Es el INSERT … SELECT de SQL cuyo SELECT no devuelve nada. Cuenta lo que has creado cuando importe, o usa MERGE sobre los nodos si deben existir.

  1. Después del ejercicio 1, ejecuta CREATE (c)-[:HAS_LESSON]->(l) entre el curso y la lección 5. ¿Impide la clave primaria de Lesson una segunda relación?
Solución

De 01-exercises.cypher, líneas 26-27:

MATCH (c:Course {slug: 'duckdb'}), (l:Lesson {number: 5}) CREATE (c)-[:HAS_LESSON]->(l);
MATCH (c:Course)-[:HAS_LESSON]->(l:Lesson) RETURN l.number, count(*) AS links ORDER BY l.number;
┌──────────┬───────┐
│ l.number │ links │
│ INT64 │ INT64 │
├──────────┼───────┤
│ 1 │ 1 │
│ 2 │ 1 │
│ 5 │ 2 │
└──────────┴───────┘

No. Las claves primarias están en los nodos; las relaciones no tienen, y se permiten dos relaciones idénticas entre los mismos nodos. Es una tabla intermedia sin restricción de unicidad sobre sus dos columnas. MERGE es la forma de evitar duplicados.