diff --git a/.env.example b/.env.example index 9564e96..e741403 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,13 @@ WANDBOX_COMPILER_OPTIONS="" WANDBOX_CSHARP_COMPILER="" # default mono-6.12.0.199 WANDBOX_CSHARP_OPTIONS="" # vacío: el subconjunto del curso no necesita flags +# Perfil sql-sqlite3-wandbox — SIN default. Verifica el compiler SQLite/SQLite3 +# estable (no *-head) contra https://wandbox.org/api/list.json y ponlo aquí. +# Sin esta variable, el perfil SQL queda registrado pero NO disponible para +# ejecutar (mismo patrón que JUDGE0_CSHARP_LANGUAGE_ID / PISTON_CSHARP_VERSION). +WANDBOX_SQL_COMPILER="" +WANDBOX_SQL_OPTIONS="" # vacío: el subconjunto SQL del curso no necesita flags + # piston / piston-selfhosted PISTON_URL="" # obligatorio con piston-selfhosted (ej http://127.0.0.1:2000) PISTON_CPP_VERSION="" diff --git a/prisma/content/courses/bases-de-datos/index.ts b/prisma/content/courses/bases-de-datos/index.ts new file mode 100644 index 0000000..8dff42e --- /dev/null +++ b/prisma/content/courses/bases-de-datos/index.ts @@ -0,0 +1,55 @@ +import { defineCourse } from "../../authoring"; + +import { bd1Fundamentos } from "./units/01-fundamentos-sgbd"; +import { bd1Requerimientos } from "./units/02-requerimientos-informacion"; +import { bd1ModeloEr } from "./units/03-modelo-er"; +import { bd1ModeloRelacional } from "./units/04-modelo-relacional"; +import { bd1Normalizacion } from "./units/05-normalizacion"; +import { bd1AlgebraRelacional } from "./units/06-algebra-relacional"; +import { bd1Ddl } from "./units/07-ddl"; +import { bd1Dml } from "./units/08-dml"; +import { bd1ConsultasReportes } from "./units/09-consultas-reportes"; +import { bd1RespaldoIntegrador } from "./units/10-respaldo-integrador"; + +// ===================================================================== +// Paquete de curso: Bases de datos (Base de Datos I, S4). +// +// Curso nuevo y lenguaje nuevo de plataforma: `sql`, perfil +// `sql-sqlite3-wandbox` (ver TECHNICAL_CONTRACT del paquete de contenido +// `bd1-ceti-content-pack`). El SQL didáctico usa el subconjunto relacional +// portable compatible con SQLite; backup/restore real y la GUI de +// escritorio que pide el programa oficial quedan como laboratorio local. +// +// 10 unidades, una sola CurriculumSection semestral, las 10 nacen +// `published: true` — sin release gate para esta entrega. +// ===================================================================== + +export const basesDeDatos = defineCourse({ + slug: "bases-de-datos", + title: "Bases de datos", + description: + "Diseña bases relacionales desde necesidades reales, normalízalas y consulta información con SQL.", + subjectName: "Bases de datos", + academicContext: "CETI · Tecnólogo en Desarrollo de Software", + language: "sql", + executionProfile: "sql-sqlite3-wandbox", + curriculum: [ + { + key: "s4-base-de-datos-1", + semester: 4, + subjectName: "Base de Datos I", + units: [ + bd1Fundamentos, + bd1Requerimientos, + bd1ModeloEr, + bd1ModeloRelacional, + bd1Normalizacion, + bd1AlgebraRelacional, + bd1Ddl, + bd1Dml, + bd1ConsultasReportes, + bd1RespaldoIntegrador, + ], + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/index.ts b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/index.ts new file mode 100644 index 0000000..4d6557a --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/index.ts @@ -0,0 +1,19 @@ +import { defineUnit } from "../../../../authoring"; + +import { leccion01 } from "./lessons/01-dato-informacion"; +import { leccion02 } from "./lessons/02-evolucion-sistemas-datos"; +import { leccion03 } from "./lessons/03-componentes-sgbd"; +import { leccion04 } from "./lessons/04-relacional-en-una-frase"; + +import { practice } from "./practice"; + +export const bd1Fundamentos = defineUnit({ + slug: "bd1-01-fundamentos-sgbd", + title: "Información, bases de datos y SGBD", + description: + "Distingue dato de información, entiende para qué sirve un SGBD y adopta el vocabulario relacional básico.", + icon: "🗄️", + published: true, + lessons: [leccion01, leccion02, leccion03, leccion04], + practice, +}); diff --git a/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/01-dato-informacion.ts b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/01-dato-informacion.ts new file mode 100644 index 0000000..e4c1eac --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/01-dato-informacion.ts @@ -0,0 +1,49 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion01 = defineLesson({ + slug: "dato-informacion", + title: "Dato, información y necesidad", + description: "Distingue dato almacenado de información útil para una decisión.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Un **dato** es una representación registrada: \`13\`, \`CERRADO\`, \`2026-09-02\`. Se vuelve información cuando se interpreta dentro de un contexto para responder una pregunta. + +Una base de datos no es valiosa por guardar mucho, sino por conservar datos con estructura suficiente para recuperarlos, relacionarlos y producir información confiable.`, + }, + { + type: "matching", + pairs: [ + { left: "13", right: "Dato sin contexto" }, + { left: "13 tickets abiertos", right: "Información contextualizada" }, + { left: "¿Qué técnico tiene más carga?", right: "Necesidad de información" }, + { left: "Conteo por técnico", right: "Resultado que ayuda a decidir" }, + ], + explanation: "La pregunta de negocio determina qué datos hay que conservar.", + }, + { + type: "quiz", + question: "¿Cuál es la mejor razón para usar una base de datos?", + options: [ + "Guardar cualquier cosa", + "Conservar y consultar datos relacionados de forma controlada", + "Evitar modelar", + "Sustituir la aplicación", + ], + correctIndex: 1, + explanation: "El SGBD ayuda a organizar, proteger y consultar información.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "datos + {{0}} + contexto → {{1}}", + blanks: [ + { answer: "estructura" }, + { answer: "información" }, + ], + explanation: "La estructura permite interpretar y relacionar datos.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/02-evolucion-sistemas-datos.ts b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/02-evolucion-sistemas-datos.ts new file mode 100644 index 0000000..1a69106 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/02-evolucion-sistemas-datos.ts @@ -0,0 +1,51 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion02 = defineLesson({ + slug: "evolucion-sistemas-datos", + title: "De archivos aislados a SGBD", + description: "Explica qué problemas resuelve un gestor frente a archivos dispersos.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Archivos sueltos pueden funcionar al inicio, pero duplican datos, dificultan búsquedas simultáneas y vuelven frágiles las reglas de integridad. + +Un SGBD centraliza mecanismos para definir estructura, consultar, modificar, controlar acceso, respaldar y recuperar información. No elimina la necesidad de buen diseño.`, + }, + { + type: "matching", + pairs: [ + { left: "Duplicación de cliente en muchos archivos", right: "Redundancia" }, + { left: "Dos nombres distintos para el mismo cliente", right: "Inconsistencia" }, + { left: "No poder relacionar ticket con técnico", right: "Falta de modelo" }, + { left: "Recuperar tras pérdida", right: "Necesidad de respaldo" }, + ], + explanation: "Los problemas de datos motivan mecanismos del SGBD.", + }, + { + type: "quiz", + question: "¿Qué afirmación es correcta?", + options: [ + "Un SGBD corrige automáticamente un mal modelo", + "Un SGBD ofrece herramientas, pero el diseño sigue siendo responsabilidad del equipo", + "SQL reemplaza requisitos", + "Una tabla siempre equivale a un archivo", + ], + correctIndex: 1, + explanation: "Herramienta y modelo son responsabilidades diferentes.", + }, + { + type: "code_completion", + prompt: "Ordena correctamente.", + lines: [ + "Necesidad de información", + "Modelo de datos", + "Estructura en SGBD", + "Datos", + "Consultas/reportes", + ], + explanation: "La tecnología aparece después de entender qué información se necesita.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/03-componentes-sgbd.ts b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/03-componentes-sgbd.ts new file mode 100644 index 0000000..76517a1 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/03-componentes-sgbd.ts @@ -0,0 +1,45 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion03 = defineLesson({ + slug: "componentes-sgbd", + title: "Componentes y responsabilidades de un SGBD", + description: "Reconoce esquema, motor, almacenamiento, consultas y administración.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `En este curso usamos "SGBD" como sistema que interpreta definiciones y consultas sobre datos persistentes. + +Conceptualmente distinguimos: **esquema** (estructura declarada), **datos**, **motor** que ejecuta operaciones, lenguaje de definición/manipulación y herramientas de administración como respaldo/restauración.`, + }, + { + type: "matching", + pairs: [ + { left: "Esquema", right: "Definición de tablas y restricciones" }, + { left: "Motor", right: "Ejecuta operaciones" }, + { left: "DDL", right: "Define estructura" }, + { left: "DML", right: "Modifica datos" }, + { left: "Backup", right: "Copia recuperable" }, + ], + explanation: "Cada pieza responde a una responsabilidad distinta.", + }, + { + type: "quiz", + question: "¿Dónde pertenece `CREATE TABLE`?", + options: ["DML", "DDL", "Reporte", "Backup"], + correctIndex: 1, + explanation: "CREATE define estructura.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "{{0}} define la estructura; {{1}} modifica datos.", + blanks: [ + { answer: "DDL" }, + { answer: "DML" }, + ], + explanation: "La separación reaparece durante todo el curso.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/04-relacional-en-una-frase.ts b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/04-relacional-en-una-frase.ts new file mode 100644 index 0000000..72949f9 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/lessons/04-relacional-en-una-frase.ts @@ -0,0 +1,45 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion04 = defineLesson({ + slug: "relacional-en-una-frase", + title: "Qué significa relacional", + description: + "Entiende tablas, filas, atributos y relaciones sin confundir \"relación\" con sólo una llave foránea.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `El modelo relacional representa información mediante **relaciones** que solemos visualizar como tablas. Cada fila representa una tupla y cada columna un atributo con significado definido. + +Relacionar información exige atributos que permitan identificar y vincular hechos sin duplicarlos innecesariamente.`, + }, + { + type: "matching", + pairs: [ + { left: "tabla/relation", right: "Conjunto estructurado de filas" }, + { left: "fila/tupla", right: "Una ocurrencia" }, + { left: "columna/atributo", right: "Propiedad definida" }, + { left: "clave", right: "Atributo(s) que identifican o vinculan" }, + ], + explanation: "Este vocabulario será la base para normalización y SQL.", + }, + { + type: "quiz", + question: "En una tabla `cliente(id,nombre)`, ¿qué representa una fila?", + options: ["Una columna", "Un cliente registrado", "Todo el esquema", "El SGBD"], + correctIndex: 1, + explanation: "La fila es una ocurrencia del tipo de hecho modelado.", + }, + { + type: "code_example", + code: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana'),(2,'Luis'); +SELECT id,nombre FROM cliente ORDER BY id;`, + explanation: "El ejemplo sólo anticipa la forma final: estructura + datos + consulta.", + runnable: true, + expectedOutput: `1|Ana +2|Luis`, + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/practice.ts b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/practice.ts new file mode 100644 index 0000000..faaaafb --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/01-fundamentos-sgbd/practice.ts @@ -0,0 +1,65 @@ +import type { PracticeExerciseDefinition } from "../../../../exercises/types"; + +export const practice = [ + { + slug: "bd1-vocabulario-tabla", + title: "Leer una tabla", + description: "Consulta todos los clientes ordenados por id.", + prompt: "Consulta todos los clientes ordenados por id.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT id,nombre FROM cliente ORDER BY id;`, + difficulty: "easy", + xpReward: 20, + testCases: [ + { + description: "dataset visible", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL');`, + expectedStdout: `1|Ana +2|Luis +3|Mara`, + visible: true, + }, + { + description: "orden diferente", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT,ciudad TEXT); INSERT INTO cliente VALUES(8,'Eva','Tlaquepaque'),(4,'Noe','GDL');`, + expectedStdout: `4|Noe +8|Eva`, + visible: false, + }, + ], + }, + { + slug: "bd1-contar-registros", + title: "Contar filas", + description: "Cuenta cuántos clientes hay.", + prompt: "Cuenta cuántos clientes hay.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT COUNT(*) FROM cliente;`, + difficulty: "easy", + xpReward: 20, + testCases: [ + { + description: "tres clientes", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL');`, + expectedStdout: `3`, + visible: true, + }, + { + description: "un cliente", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT,ciudad TEXT); INSERT INTO cliente VALUES(1,'A','X');`, + expectedStdout: `1`, + visible: false, + }, + { + description: "tabla vacía", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT,ciudad TEXT);`, + expectedStdout: `0`, + visible: false, + }, + ], + }, +] satisfies PracticeExerciseDefinition[]; diff --git a/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/index.ts b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/index.ts new file mode 100644 index 0000000..a48567b --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/index.ts @@ -0,0 +1,19 @@ +import { defineUnit } from "../../../../authoring"; + +import { leccion01 } from "./lessons/01-preguntas-antes-tablas"; +import { leccion02 } from "./lessons/02-tecnicas-recoleccion"; +import { leccion03 } from "./lessons/03-abstraer-clasificar-filtrar"; +import { leccion04 } from "./lessons/04-requerimientos-documentados"; + +import { practice } from "./practice"; + +export const bd1Requerimientos = defineUnit({ + slug: "bd1-02-requerimientos-informacion", + title: "Requerimientos de información", + description: + "Recolecta y documenta requerimientos de información antes de diseñar cualquier tabla.", + icon: "🔎", + published: true, + lessons: [leccion01, leccion02, leccion03, leccion04], + practice, +}); diff --git a/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/01-preguntas-antes-tablas.ts b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/01-preguntas-antes-tablas.ts new file mode 100644 index 0000000..d20f092 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/01-preguntas-antes-tablas.ts @@ -0,0 +1,46 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion01 = defineLesson({ + slug: "preguntas-antes-tablas", + title: "Preguntas antes que tablas", + description: "Parte de decisiones y consultas necesarias antes de diseñar estructura.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Diseñar una base empezando por nombres de tablas suele producir estructura sin propósito. Primero pregunta qué necesita saber cada actor. + +Ejemplo: "¿qué tickets abiertos tiene cada técnico?" obliga a conservar ticket, estado, asignación y técnico. La **consulta futura** ayuda a descubrir datos necesarios.`, + }, + { + type: "matching", + pairs: [ + { left: "¿Quién reportó el ticket?", right: "Cliente/usuario reportante" }, + { left: "¿Quién lo atiende?", right: "Técnico/asignación" }, + { left: "¿Está pendiente?", right: "Estado" }, + { left: "¿Cuánto costó?", right: "Costo del servicio" }, + ], + explanation: "Las necesidades de información revelan hechos a modelar.", + }, + { + type: "quiz", + question: "¿Qué debería ocurrir antes de crear tablas?", + options: [ + "Elegir colores", + "Identificar necesidades y reglas de información", + "Crear 20 columnas genéricas", + "Agregar índices", + ], + correctIndex: 1, + explanation: "El esquema nace de requerimientos, no al revés.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "pregunta de negocio → datos necesarios → {{0}} → consultas", + blanks: [{ answer: "modelo" }], + explanation: "El modelo organiza datos para responder preguntas.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/02-tecnicas-recoleccion.ts b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/02-tecnicas-recoleccion.ts new file mode 100644 index 0000000..f7069ad --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/02-tecnicas-recoleccion.ts @@ -0,0 +1,46 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion02 = defineLesson({ + slug: "tecnicas-recoleccion", + title: "Entrevista, encuesta y observación", + description: "Elige técnica de recolección según el tipo de evidencia.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Una entrevista permite profundizar en reglas y excepciones. Una encuesta escala respuestas estructuradas. La observación revela cómo ocurre realmente el proceso y puede descubrir diferencias entre lo dicho y lo hecho. + +No son competidoras: un análisis real puede combinarlas.`, + }, + { + type: "matching", + pairs: [ + { left: "Entrevista", right: "Explorar reglas y excepciones" }, + { left: "Encuesta", right: "Recoger respuestas comparables de muchas personas" }, + { left: "Observación", right: "Ver proceso real y datos que aparecen" }, + ], + explanation: "La técnica depende de la pregunta.", + }, + { + type: "quiz", + question: + "El usuario afirma que siempre registra el número de serie, pero sospechas que en operación real no ocurre. ¿Qué técnica aporta evidencia directa?", + options: ["Observación", "Sólo encuesta", "Normalización", "SQL"], + correctIndex: 0, + explanation: "Observar el proceso permite contrastar práctica y descripción.", + }, + { + type: "code_completion", + prompt: "Ordena correctamente.", + lines: [ + "Definir qué se quiere conocer", + "Elegir técnica", + "Recolectar evidencia", + "Clasificar/filtrar datos", + "Documentar requerimientos", + ], + explanation: "La recolección también necesita un objetivo.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/03-abstraer-clasificar-filtrar.ts b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/03-abstraer-clasificar-filtrar.ts new file mode 100644 index 0000000..7cc80c3 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/03-abstraer-clasificar-filtrar.ts @@ -0,0 +1,46 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion03 = defineLesson({ + slug: "abstraer-clasificar-filtrar", + title: "Abstracción, clasificación y filtrado", + description: "Separa datos relevantes del ruido y agrúpalos por significado.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Abstraer es conservar las propiedades relevantes para el problema. Clasificar agrupa datos con significado común. Filtrar excluye lo que no aporta al objetivo o pertenece fuera del alcance. + +Guardar todo "por si acaso" aumenta coste, riesgo y confusión.`, + }, + { + type: "matching", + pairs: [ + { left: "color favorito del técnico", right: "Normalmente fuera del dominio de soporte" }, + { left: "folio del ticket", right: "Identidad operativa" }, + { left: "estado", right: "Dato necesario para flujo" }, + { left: "fecha de cierre", right: "Dato útil para seguimiento/reportes" }, + ], + explanation: "La relevancia depende del problema.", + }, + { + type: "quiz", + question: "¿Qué criterio decide si un dato entra al modelo?", + options: [ + "Que sea fácil obtenerlo", + "Que sea necesario para requisito, regla o consulta justificada", + "Que sea texto", + "Que lo tenga otro sistema", + ], + correctIndex: 1, + explanation: "Cada dato debería poder justificar su existencia.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "Dato relevante = responde a {{0}} o soporta una {{1}}.", + blanks: [{ answer: "requisito" }, { answer: "regla" }], + explanation: "Evita coleccionar campos sin propósito.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/04-requerimientos-documentados.ts b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/04-requerimientos-documentados.ts new file mode 100644 index 0000000..06267eb --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/lessons/04-requerimientos-documentados.ts @@ -0,0 +1,46 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion04 = defineLesson({ + slug: "requerimientos-documentados", + title: "Documento de requerimientos de información", + description: "Escribe requerimientos que luego puedan mapearse al modelo.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Un requerimiento de información debe decir qué se necesita conocer, sobre qué entidades/hechos y con qué reglas relevantes. + +Ejemplo: "El sistema debe permitir consultar tickets por cliente y estado, conservando quién los atiende y su costo final." Esto ya orienta el modelo sin imponer tablas concretas.`, + }, + { + type: "matching", + pairs: [ + { left: "Necesidad", right: "Consultar tickets abiertos" }, + { left: "Dato", right: "Estado" }, + { left: "Regla", right: "Un ticket tiene un cliente" }, + { left: "Reporte", right: "Tickets abiertos por cliente" }, + ], + explanation: "El documento conecta preguntas con datos/reglas.", + }, + { + type: "quiz", + question: "¿Cuál enunciado es demasiado técnico para esta fase?", + options: [ + "Consultar tickets por cliente", + "Conservar fecha de cierre", + "Usar exactamente tres índices B-tree", + "Registrar técnico asignado", + ], + correctIndex: 2, + explanation: "El índice es una decisión física prematura para BD I.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "Requerimiento → entidades/hechos → atributos → {{0}}", + blanks: [{ answer: "relaciones" }], + explanation: "El siguiente paso es modelar.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/practice.ts b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/practice.ts new file mode 100644 index 0000000..357e790 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/02-requerimientos-informacion/practice.ts @@ -0,0 +1,64 @@ +import type { PracticeExerciseDefinition } from "../../../../exercises/types"; + +export const practice = [ + { + slug: "bd1-requerimiento-filtrar", + title: "Filtrar por necesidad", + description: "Devuelve sólo clientes de la ciudad GDL, ordenados por id.", + prompt: "Devuelve sólo clientes de la ciudad `GDL`, ordenados por id.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT id,nombre FROM cliente WHERE ciudad='GDL' ORDER BY id;`, + difficulty: "easy", + xpReward: 20, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL');`, + expectedStdout: `1|Ana +3|Mara`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT,ciudad TEXT); INSERT INTO cliente VALUES(4,'Iris','GDL'),(2,'Omar','Zapopan'),(9,'Sol','GDL');`, + expectedStdout: `4|Iris +9|Sol`, + visible: false, + }, + ], + }, + { + slug: "bd1-requerimiento-estado", + title: "Responder una pregunta", + description: "Cuenta tickets ABIERTO.", + prompt: "Cuenta tickets `ABIERTO`.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT COUNT(*) FROM ticket WHERE estado='ABIERTO';`, + difficulty: "easy", + xpReward: 20, + testCases: [ + { + description: "dos abiertos", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `2`, + visible: true, + }, + { + description: "ninguno", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO ticket VALUES(1,1,'CERRADO',1),(2,1,'CERRADO',2);`, + expectedStdout: `0`, + visible: false, + }, + { + description: "uno", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO ticket VALUES(1,1,'ABIERTO',1);`, + expectedStdout: `1`, + visible: false, + }, + ], + }, +] satisfies PracticeExerciseDefinition[]; diff --git a/prisma/content/courses/bases-de-datos/units/03-modelo-er/index.ts b/prisma/content/courses/bases-de-datos/units/03-modelo-er/index.ts new file mode 100644 index 0000000..d9065bc --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/03-modelo-er/index.ts @@ -0,0 +1,20 @@ +import { defineUnit } from "../../../../authoring"; + +import { leccion01 } from "./lessons/01-entidades-atributos"; +import { leccion02 } from "./lessons/02-identificadores"; +import { leccion03 } from "./lessons/03-cardinalidades"; +import { leccion04 } from "./lessons/04-relacion-muchos-muchos"; +import { leccion05 } from "./lessons/05-modelo-er-del-caso"; + +import { practice } from "./practice"; + +export const bd1ModeloEr = defineUnit({ + slug: "bd1-03-modelo-er", + title: "Modelo Entidad-Relación", + description: + "Modela entidades, atributos, identificadores y cardinalidades del sistema de soporte técnico.", + icon: "🧩", + published: true, + lessons: [leccion01, leccion02, leccion03, leccion04, leccion05], + practice, +}); diff --git a/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/01-entidades-atributos.ts b/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/01-entidades-atributos.ts new file mode 100644 index 0000000..ddc48ab --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/01-entidades-atributos.ts @@ -0,0 +1,46 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion01 = defineLesson({ + slug: "entidades-atributos", + title: "Entidades y atributos", + description: "Identifica entidades del dominio y atributos que realmente las describen.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Una entidad representa un tipo de cosa o hecho con identidad relevante para el sistema. Un atributo describe una propiedad. + +No todo sustantivo merece entidad. \`Ciudad\` puede ser sólo atributo si no tiene identidad/reglas propias; podría convertirse en entidad si el sistema administra catálogo, regiones u otras relaciones.`, + }, + { + type: "matching", + pairs: [ + { left: "Cliente", right: "Entidad" }, + { left: "nombre de cliente", right: "Atributo" }, + { left: "Ticket", right: "Entidad" }, + { left: "estado de ticket", right: "Atributo" }, + ], + explanation: "La frontera depende del dominio.", + }, + { + type: "quiz", + question: "¿Cuál criterio favorece modelar algo como entidad?", + options: [ + "Tiene identidad y participa en relaciones/reglas propias", + "Es una palabra larga", + "Aparece una vez", + "Es texto", + ], + correctIndex: 0, + explanation: "La identidad y comportamiento informacional son claves.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "Entidad: {{0}}; atributo: propiedad de esa entidad.", + blanks: [{ answer: "tipo de objeto/hecho con identidad" }], + explanation: "No confundas objeto modelado con una de sus propiedades.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/02-identificadores.ts b/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/02-identificadores.ts new file mode 100644 index 0000000..d17771a --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/02-identificadores.ts @@ -0,0 +1,41 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion02 = defineLesson({ + slug: "identificadores", + title: "Identificadores y claves candidatas", + description: "Elige identificadores estables sin usar atributos cambiantes.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Una clave candidata identifica de forma única una ocurrencia. La clave primaria selecciona una de ellas como identificador principal del modelo relacional. + +Un nombre humano suele ser mal identificador porque puede repetirse o cambiar. Un folio de ticket puede ser natural si su unicidad y estabilidad son reglas del negocio.`, + }, + { + type: "matching", + pairs: [ + { left: "id_cliente", right: "Identificador artificial estable" }, + { left: "folio_ticket", right: "Posible clave natural" }, + { left: "nombre_cliente", right: "Mala clave si puede repetirse" }, + { left: "correo", right: "Sólo clave si unicidad/estabilidad son reglas" }, + ], + explanation: "La clave necesita propiedades explícitas.", + }, + { + type: "quiz", + question: "Dos clientes pueden llamarse Ana. ¿Debe `nombre` ser PK?", + options: ["Sí", "No", "Sólo si es TEXT", "Sólo con SQLite"], + correctIndex: 1, + explanation: "Un identificador debe ser único.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "Una buena clave debe ser {{0}} y estable.", + blanks: [{ answer: "única" }], + explanation: "La estabilidad evita que relaciones dependan de datos cambiantes.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/03-cardinalidades.ts b/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/03-cardinalidades.ts new file mode 100644 index 0000000..9fab9b9 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/03-cardinalidades.ts @@ -0,0 +1,47 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion03 = defineLesson({ + slug: "cardinalidades", + title: "Cardinalidad uno a uno, uno a muchos y muchos a muchos", + description: "Representa cuántas ocurrencias pueden relacionarse.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Cardinalidad expresa una regla del dominio. Un cliente puede tener muchos tickets; cada ticket pertenece a un cliente: 1:N. Un técnico puede trabajar muchos tickets y un ticket podría tener varios técnicos si el negocio lo permite: potencial N:M. + +La cardinalidad no se adivina por costumbre; se pregunta.`, + }, + { + type: "matching", + pairs: [ + { left: "Cliente → Ticket", right: "1:N" }, + { left: "Ticket → Cliente", right: "N:1" }, + { left: "Alumno ↔ Curso", right: "N:M típico" }, + { left: "Persona ↔ Perfil único", right: "1:1 posible" }, + ], + explanation: "Lee ambas direcciones.", + }, + { + type: "quiz", + question: + "Si cada ticket tiene exactamente un cliente y un cliente puede tener muchos tickets, ¿qué cardinalidad es?", + options: [ + "1:1", + "1:N de Cliente a Ticket", + "N:M", + "N:1 de Cliente a Ticket", + ], + correctIndex: 1, + explanation: "Una ocurrencia de cliente se relaciona con muchas de ticket.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "Cliente 1 —— N {{0}}", + blanks: [{ answer: "Ticket" }], + explanation: "La notación resume una regla del dominio.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/04-relacion-muchos-muchos.ts b/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/04-relacion-muchos-muchos.ts new file mode 100644 index 0000000..340636b --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/04-relacion-muchos-muchos.ts @@ -0,0 +1,41 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion04 = defineLesson({ + slug: "relacion-muchos-muchos", + title: "Resolver relaciones N:M conceptualmente", + description: "Reconoce cuándo la relación necesita atributos propios.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Una relación N:M suele transformarse después en una tabla asociativa. Antes de pensar en SQL, observa si la relación tiene datos propios. + +Si varios técnicos atienden varios tickets y necesitamos \`horas_trabajadas\`, ese dato pertenece a la **asignación**, no al técnico ni al ticket por separado.`, + }, + { + type: "matching", + pairs: [ + { left: "Tecnico", right: "Entidad" }, + { left: "Ticket", right: "Entidad" }, + { left: "Asignacion", right: "Relación/entidad asociativa" }, + { left: "horas_trabajadas", right: "Atributo de Asignacion" }, + ], + explanation: "Los atributos de la relación revelan la entidad asociativa.", + }, + { + type: "quiz", + question: "¿Dónde guardar `horas_trabajadas` si depende de técnico + ticket?", + options: ["Tecnico", "Ticket", "Asignacion técnico-ticket", "Cliente"], + correctIndex: 2, + explanation: "El valor existe para el par.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "N:M + datos propios → {{0}} asociativa", + blanks: [{ answer: "entidad" }], + explanation: "Luego se convierte en tabla con FKs.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/05-modelo-er-del-caso.ts b/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/05-modelo-er-del-caso.ts new file mode 100644 index 0000000..80bcbc6 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/03-modelo-er/lessons/05-modelo-er-del-caso.ts @@ -0,0 +1,54 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion05 = defineLesson({ + slug: "modelo-er-del-caso", + title: "Construir el ER del sistema de soporte", + description: "Integra entidades, claves y cardinalidades del dominio recurrente.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Modelo base del curso: + +- Cliente 1:N Equipo +- Cliente 1:N Ticket +- Equipo 1:N Ticket +- Técnico N:M Ticket mediante Asignación +- Asignación conserva horas + +Este modelo es un punto de partida didáctico. Cada relación debe justificarse por las necesidades declaradas.`, + }, + { + type: "matching", + pairs: [ + { left: "Cliente", right: "id_cliente" }, + { left: "Equipo", right: "id_equipo" }, + { left: "Ticket", right: "id_ticket" }, + { left: "Tecnico", right: "id_tecnico" }, + { left: "Asignacion", right: "id_ticket + id_tecnico" }, + ], + explanation: "La asociativa puede usar clave compuesta.", + }, + { + type: "quiz", + question: "¿Qué entidad rompe directamente el N:M Técnico↔Ticket?", + options: ["Cliente", "Equipo", "Asignacion", "Estado"], + correctIndex: 2, + explanation: "Asignacion representa cada vínculo técnico-ticket.", + }, + { + type: "code_completion", + prompt: "Ordena correctamente.", + lines: [ + "Identificar necesidades", + "Definir entidades", + "Elegir identificadores", + "Agregar atributos", + "Definir relaciones/cardinalidades", + "Revisar contra consultas", + ], + explanation: "El modelo debe volver a las preguntas originales.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/03-modelo-er/practice.ts b/prisma/content/courses/bases-de-datos/units/03-modelo-er/practice.ts new file mode 100644 index 0000000..edbb3a0 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/03-modelo-er/practice.ts @@ -0,0 +1,92 @@ +import type { PracticeExerciseDefinition } from "../../../../exercises/types"; + +export const practice = [ + { + slug: "bd1-er-cliente-ticket", + title: "Relación cliente-ticket", + description: "Consulta cada ticket con su cliente_id, ordenado por ticket.", + prompt: + "Consulta cada ticket con su `cliente_id`, ordenado por ticket. Este ejercicio prepara la futura FK.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT id,cliente_id FROM ticket ORDER BY id;`, + difficulty: "easy", + xpReward: 20, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `10|1 +11|1 +12|2 +13|3`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO ticket VALUES(7,9,'A',0),(2,4,'B',0);`, + expectedStdout: `2|4 +7|9`, + visible: false, + }, + ], + }, + { + slug: "bd1-er-asignacion", + title: "Tabla asociativa", + description: "Devuelve ticket_id|tecnico_id|horas de todas las asignaciones ordenadas por ambos ids.", + prompt: "Devuelve `ticket_id|tecnico_id|horas` de todas las asignaciones ordenadas por ambos ids.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT ticket_id,tecnico_id,horas FROM asignacion ORDER BY ticket_id,tecnico_id;`, + difficulty: "easy", + xpReward: 20, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE asignacion(ticket_id INTEGER,tecnico_id INTEGER,horas INTEGER); INSERT INTO asignacion VALUES(10,2,3),(10,1,1),(12,2,4);`, + expectedStdout: `10|1|1 +10|2|3 +12|2|4`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE asignacion(ticket_id INTEGER,tecnico_id INTEGER,horas INTEGER); INSERT INTO asignacion VALUES(4,8,2);`, + expectedStdout: `4|8|2`, + visible: false, + }, + ], + }, + { + slug: "bd1-er-clave-compuesta", + title: "Unicidad de asignación", + description: + "Crea asignacion con PRIMARY KEY compuesta (ticket_id, tecnico_id) y consulta el orden de la clave.", + prompt: + "Crea `asignacion` con PRIMARY KEY compuesta `(ticket_id, tecnico_id)` y termina con `SELECT pk FROM pragma_table_info('asignacion') WHERE name IN ('ticket_id','tecnico_id') ORDER BY pk;`.", + starterCode: `-- crea la tabla asignacion y luego ejecuta la consulta PRAGMA indicada +`, + solutionCode: `CREATE TABLE asignacion(ticket_id INTEGER NOT NULL, tecnico_id INTEGER NOT NULL, horas INTEGER NOT NULL, PRIMARY KEY(ticket_id,tecnico_id)); +SELECT pk FROM pragma_table_info('asignacion') WHERE name IN ('ticket_id','tecnico_id') ORDER BY pk;`, + difficulty: "medium", + xpReward: 30, + testCases: [ + { + description: "estructura", + stdin: ``, + expectedStdout: `1 +2`, + visible: true, + }, + { + description: "repetición independiente", + stdin: ``, + expectedStdout: `1 +2`, + visible: false, + }, + ], + }, +] satisfies PracticeExerciseDefinition[]; diff --git a/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/index.ts b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/index.ts new file mode 100644 index 0000000..18cd870 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/index.ts @@ -0,0 +1,20 @@ +import { defineUnit } from "../../../../authoring"; + +import { leccion01 } from "./lessons/01-tablas-y-dominios"; +import { leccion02 } from "./lessons/02-pk-fk"; +import { leccion03 } from "./lessons/03-nulabilidad-integridad"; +import { leccion04 } from "./lessons/04-restricciones-check-unique"; +import { leccion05 } from "./lessons/05-traducir-nm"; + +import { practice } from "./practice"; + +export const bd1ModeloRelacional = defineUnit({ + slug: "bd1-04-modelo-relacional", + title: "Del modelo ER al modelo relacional", + description: + "Traduce entidades y relaciones a tablas, claves primarias/foráneas y restricciones.", + icon: "🔗", + published: true, + lessons: [leccion01, leccion02, leccion03, leccion04, leccion05], + practice, +}); diff --git a/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/01-tablas-y-dominios.ts b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/01-tablas-y-dominios.ts new file mode 100644 index 0000000..e82740e --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/01-tablas-y-dominios.ts @@ -0,0 +1,46 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion01 = defineLesson({ + slug: "tablas-y-dominios", + title: "Relaciones, atributos y dominios", + description: "Traduce entidades a relaciones con columnas de significado claro.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Al pasar a modelo relacional, una entidad suele convertirse en relación/tabla y sus atributos en columnas. Cada atributo debe tener un dominio coherente: tipo y reglas admitidas. + +El tipo SQL no reemplaza el significado. \`TEXT\` no dice si un valor representa nombre, estado o folio.`, + }, + { + type: "matching", + pairs: [ + { left: "Cliente", right: "tabla" }, + { left: "id_cliente", right: "clave primaria" }, + { left: "nombre", right: "atributo" }, + { left: "TEXT/INTEGER", right: "dominio físico aproximado" }, + ], + explanation: "El esquema combina significado y representación.", + }, + { + type: "quiz", + question: "¿Qué error es más grave?", + options: [ + "Usar nombres claros", + "Guardar `estado`, `nombre` y `folio` en una sola columna genérica", + "Definir PK", + "Separar entidades", + ], + correctIndex: 1, + explanation: "Una columna debe representar un atributo coherente.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "Entidad → {{0}}; atributo → columna.", + blanks: [{ answer: "tabla" }], + explanation: "Es la traducción inicial del ER.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/02-pk-fk.ts b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/02-pk-fk.ts new file mode 100644 index 0000000..f9a08da --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/02-pk-fk.ts @@ -0,0 +1,53 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion02 = defineLesson({ + slug: "pk-fk", + title: "Claves primarias y foráneas", + description: "Implementa relaciones 1:N mediante FK.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `La PK identifica una fila. La FK expresa que un valor referencia una fila de otra relación. + +Para Cliente 1:N Ticket, \`ticket.cliente_id\` referencia \`cliente.id\`. No copiamos nombre, ciudad y demás datos del cliente en cada ticket.`, + }, + { + type: "code_example", + code: `PRAGMA foreign_keys=ON; +CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT NOT NULL); +CREATE TABLE ticket( + id INTEGER PRIMARY KEY, + cliente_id INTEGER NOT NULL, + FOREIGN KEY(cliente_id) REFERENCES cliente(id) +); +INSERT INTO cliente VALUES(1,'Ana'); +INSERT INTO ticket VALUES(10,1); +SELECT ticket.id,cliente.nombre +FROM ticket JOIN cliente ON cliente.id=ticket.cliente_id;`, + explanation: "La FK conserva el vínculo; JOIN recupera datos relacionados.", + runnable: true, + expectedOutput: `10|Ana`, + }, + { + type: "quiz", + question: "¿Dónde debe vivir `cliente_id` en una relación Cliente 1:N Ticket?", + options: [ + "En Cliente apuntando a un ticket", + "En Ticket apuntando al cliente", + "En ambos duplicado", + "En ninguna tabla", + ], + correctIndex: 1, + explanation: "El lado N guarda la FK al lado 1.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "FOREIGN KEY(cliente_id) REFERENCES {{0}}({{1}})", + blanks: [{ answer: "cliente" }, { answer: "id" }], + explanation: "La referencia declara destino.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/03-nulabilidad-integridad.ts b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/03-nulabilidad-integridad.ts new file mode 100644 index 0000000..540d344 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/03-nulabilidad-integridad.ts @@ -0,0 +1,41 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion03 = defineLesson({ + slug: "nulabilidad-integridad", + title: "Nulos e integridad", + description: "Decide obligatoriedad según reglas del dominio.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `\`NOT NULL\` debe responder a una regla real. Si todo ticket debe pertenecer a un cliente, \`cliente_id\` es obligatorio. Si el técnico puede asignarse más tarde, una relación directa \`tecnico_id\` podría ser nullable. + +No uses NULL como valor mágico para "cualquier cosa"; representa ausencia/desconocimiento según el modelo.`, + }, + { + type: "matching", + pairs: [ + { left: "cliente_id en Ticket", right: "NOT NULL si todo ticket tiene cliente" }, + { left: "fecha_cierre", right: "Puede ser NULL mientras esté abierto" }, + { left: "nombre_cliente", right: "NOT NULL si es obligatorio" }, + { left: "costo_final", right: "Puede ser NULL hasta terminar servicio" }, + ], + explanation: "La nulabilidad expresa estado permitido.", + }, + { + type: "quiz", + question: "¿Qué representa mejor `fecha_cierre NULL` en un ticket abierto?", + options: ["Cero", "No existe fecha de cierre todavía", "Error de SQL", "Cadena vacía"], + correctIndex: 1, + explanation: "NULL representa ausencia del valor.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "Atributo obligatorio → {{0}}", + blanks: [{ answer: "NOT NULL" }], + explanation: "La restricción debe reflejar el requerimiento.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/04-restricciones-check-unique.ts b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/04-restricciones-check-unique.ts new file mode 100644 index 0000000..2381a0e --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/04-restricciones-check-unique.ts @@ -0,0 +1,44 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion04 = defineLesson({ + slug: "restricciones-check-unique", + title: "UNIQUE y CHECK como reglas simples", + description: "Mueve invariantes simples al esquema cuando corresponda.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Una base relacional puede rechazar estados imposibles con restricciones. \`UNIQUE\` evita duplicados donde la unicidad es regla. \`CHECK\` restringe valores simples. + +No toda regla de negocio cabe en un CHECK, pero las invariantes de datos básicas ganan valor si el SGBD también las protege.`, + }, + { + type: "code_example", + code: `CREATE TABLE ticket( + id INTEGER PRIMARY KEY, + folio TEXT NOT NULL UNIQUE, + estado TEXT NOT NULL CHECK(estado IN ('ABIERTO','CERRADO')) +); +INSERT INTO ticket VALUES(1,'T-1','ABIERTO'); +SELECT folio,estado FROM ticket;`, + explanation: "UNIQUE protege folio y CHECK limita estado.", + runnable: true, + expectedOutput: `T-1|ABIERTO`, + }, + { + type: "quiz", + question: "¿Cuál restricción protege que no existan dos tickets con mismo folio?", + options: ["NOT NULL", "UNIQUE", "FOREIGN KEY", "ORDER BY"], + correctIndex: 1, + explanation: "UNIQUE expresa unicidad.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "estado TEXT NOT NULL {{0}}(estado IN ('ABIERTO','CERRADO'))", + blanks: [{ answer: "CHECK" }], + explanation: "CHECK valida valores admitidos.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/05-traducir-nm.ts b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/05-traducir-nm.ts new file mode 100644 index 0000000..00f2414 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/lessons/05-traducir-nm.ts @@ -0,0 +1,52 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion05 = defineLesson({ + slug: "traducir-nm", + title: "N:M en el modelo relacional", + description: "Crea tabla asociativa con dos FKs y una clave que evite duplicar el vínculo.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Una N:M se representa con una relación intermedia. \`asignacion(ticket_id, tecnico_id, horas)\` referencia ambas entidades. + +Si un técnico sólo debe aparecer una vez por ticket, \`(ticket_id, tecnico_id)\` puede ser PK compuesta.`, + }, + { + type: "code_example", + code: `PRAGMA foreign_keys=ON; +CREATE TABLE ticket(id INTEGER PRIMARY KEY); +CREATE TABLE tecnico(id INTEGER PRIMARY KEY,nombre TEXT NOT NULL); +CREATE TABLE asignacion( + ticket_id INTEGER NOT NULL, + tecnico_id INTEGER NOT NULL, + horas INTEGER NOT NULL, + PRIMARY KEY(ticket_id,tecnico_id), + FOREIGN KEY(ticket_id) REFERENCES ticket(id), + FOREIGN KEY(tecnico_id) REFERENCES tecnico(id) +); +INSERT INTO ticket VALUES(1); +INSERT INTO tecnico VALUES(7,'Ana'); +INSERT INTO asignacion VALUES(1,7,3); +SELECT ticket_id,tecnico_id,horas FROM asignacion;`, + explanation: "La asociativa convierte N:M en dos relaciones 1:N.", + runnable: true, + expectedOutput: `1|7|3`, + }, + { + type: "quiz", + question: "¿Dónde debe vivir `horas`?", + options: ["Ticket", "Tecnico", "Asignacion", "Cliente"], + correctIndex: 2, + explanation: "Depende del par técnico-ticket.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "PRIMARY KEY({{0}},{{1}})", + blanks: [{ answer: "ticket_id" }, { answer: "tecnico_id" }], + explanation: "La clave compuesta identifica el vínculo.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/practice.ts b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/practice.ts new file mode 100644 index 0000000..ab53f00 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/04-modelo-relacional/practice.ts @@ -0,0 +1,116 @@ +import type { PracticeExerciseDefinition } from "../../../../exercises/types"; + +export const practice = [ + { + slug: "bd1-relacional-fk", + title: "Crear una FK", + description: "Crea ticket(id PK, cliente_id NOT NULL FK cliente(id)) y consulta la tabla referenciada.", + prompt: + "Crea `ticket(id PK, cliente_id NOT NULL FK cliente(id))` y consulta con PRAGMA la tabla referenciada.", + starterCode: `CREATE TABLE cliente(id INTEGER PRIMARY KEY); +-- crea ticket +-- termina con: SELECT "table" FROM pragma_foreign_key_list('ticket'); +`, + solutionCode: `CREATE TABLE cliente(id INTEGER PRIMARY KEY); +CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, FOREIGN KEY(cliente_id) REFERENCES cliente(id)); +SELECT "table" FROM pragma_foreign_key_list('ticket');`, + difficulty: "medium", + xpReward: 30, + testCases: [ + { + description: "estructura", + stdin: ``, + expectedStdout: `cliente`, + visible: true, + }, + { + description: "independiente", + stdin: ``, + expectedStdout: `cliente`, + visible: false, + }, + ], + }, + { + slug: "bd1-relacional-check", + title: "Restringir estado", + description: "Crea Ticket con CHECK que sólo admita ABIERTO/CERRADO; inserta ABIERTO y selecciona estado.", + prompt: + "Crea Ticket con CHECK que sólo admita ABIERTO/CERRADO; inserta ABIERTO y selecciona estado.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `CREATE TABLE ticket(id INTEGER PRIMARY KEY,estado TEXT NOT NULL CHECK(estado IN ('ABIERTO','CERRADO'))); +INSERT INTO ticket VALUES(1,'ABIERTO'); +SELECT estado FROM ticket;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "válido", + stdin: ``, + expectedStdout: `ABIERTO`, + visible: true, + }, + { + description: "repetición", + stdin: ``, + expectedStdout: `ABIERTO`, + visible: false, + }, + ], + }, + { + slug: "bd1-relacional-null", + title: "Detectar pendientes", + description: "Consulta ids donde fecha_cierre IS NULL ordenados.", + prompt: "Consulta ids donde `fecha_cierre IS NULL` ordenados.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT id FROM ticket WHERE fecha_cierre IS NULL ORDER BY id;`, + difficulty: "easy", + xpReward: 20, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE ticket(id INTEGER,fecha_cierre TEXT); INSERT INTO ticket VALUES(1,NULL),(2,'2026-01-01'),(3,NULL);`, + expectedStdout: `1 +3`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE ticket(id INTEGER,fecha_cierre TEXT); INSERT INTO ticket VALUES(9,'x'),(4,NULL);`, + expectedStdout: `4`, + visible: false, + }, + ], + }, + { + slug: "bd1-relacional-asociativa", + title: "Consultar asignaciones", + description: + "Devuelve técnicos del ticket indicado por datos del fixture; fixture sólo contiene un ticket objetivo marcado objetivo=1.", + prompt: + "Devuelve técnicos del ticket indicado por datos del fixture; fixture sólo contiene un ticket objetivo marcado `objetivo=1`.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT tecnico_id,horas FROM asignacion WHERE objetivo=1 ORDER BY tecnico_id;`, + difficulty: "easy", + xpReward: 20, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE asignacion(ticket_id INTEGER,tecnico_id INTEGER,horas INTEGER,objetivo INTEGER); INSERT INTO asignacion VALUES(10,2,3,1),(10,1,1,1),(11,8,2,0);`, + expectedStdout: `1|1 +2|3`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE asignacion(ticket_id INTEGER,tecnico_id INTEGER,horas INTEGER,objetivo INTEGER); INSERT INTO asignacion VALUES(1,5,7,1);`, + expectedStdout: `5|7`, + visible: false, + }, + ], + }, +] satisfies PracticeExerciseDefinition[]; diff --git a/prisma/content/courses/bases-de-datos/units/05-normalizacion/index.ts b/prisma/content/courses/bases-de-datos/units/05-normalizacion/index.ts new file mode 100644 index 0000000..d1f7f0a --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/05-normalizacion/index.ts @@ -0,0 +1,19 @@ +import { defineUnit } from "../../../../authoring"; + +import { leccion01 } from "./lessons/01-por-que-normalizar"; +import { leccion02 } from "./lessons/02-primera-forma-normal"; +import { leccion03 } from "./lessons/03-segunda-forma-normal"; +import { leccion04 } from "./lessons/04-tercera-forma-normal"; +import { leccion05 } from "./lessons/05-normalizar-caso"; + +import { practice } from "./practice"; + +export const bd1Normalizacion = defineUnit({ + slug: "bd1-05-normalizacion", + title: "Normalización", + description: "Reduce redundancia y anomalías aplicando 1FN, 2FN y 3FN a un caso completo.", + icon: "🧹", + published: true, + lessons: [leccion01, leccion02, leccion03, leccion04, leccion05], + practice, +}); diff --git a/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/01-por-que-normalizar.ts b/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/01-por-que-normalizar.ts new file mode 100644 index 0000000..6d74627 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/01-por-que-normalizar.ts @@ -0,0 +1,45 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion01 = defineLesson({ + slug: "por-que-normalizar", + title: "Redundancia y anomalías", + description: "Reconoce anomalías de inserción, actualización y eliminación.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Normalizar busca representar hechos una sola vez según sus dependencias. La meta no es "hacer más tablas"; es reducir redundancia que produce contradicciones. + +Si cada ticket repite nombre y ciudad del cliente, cambiar su ciudad exige actualizar muchas filas. Olvidar una crea inconsistencia.`, + }, + { + type: "matching", + pairs: [ + { left: "Cambiar ciudad en 20 tickets", right: "Anomalía de actualización" }, + { left: "No poder registrar cliente sin ticket", right: "Anomalía de inserción" }, + { left: "Borrar último ticket elimina datos del cliente", right: "Anomalía de eliminación" }, + ], + explanation: "Las anomalías revelan hechos mezclados.", + }, + { + type: "quiz", + question: "¿Qué síntoma sugiere normalización insuficiente?", + options: [ + "Cada hecho vive una vez", + "El mismo nombre de cliente se repite en todas sus ventas", + "Hay PK", + "Hay FK", + ], + correctIndex: 1, + explanation: "La repetición de hechos independientes crea riesgo.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "redundancia → anomalías → {{0}}", + blanks: [{ answer: "normalización" }], + explanation: "Normalizar responde a problemas concretos.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/02-primera-forma-normal.ts b/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/02-primera-forma-normal.ts new file mode 100644 index 0000000..b94ba43 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/02-primera-forma-normal.ts @@ -0,0 +1,40 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion02 = defineLesson({ + slug: "primera-forma-normal", + title: "Primera forma normal", + description: "Evita grupos repetidos y valores no atómicos para el modelo elegido.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Para este curso, 1FN significa que cada celda representa un valor del atributo y no hay grupos repetidos como \`telefono1, telefono2, telefono3\`. + +Si un cliente puede tener muchos teléfonos, modela \`telefono_cliente\` como relación aparte en lugar de reservar columnas arbitrarias.`, + }, + { + type: "matching", + pairs: [ + { left: "telefono1,telefono2,telefono3", right: "Grupo repetido" }, + { left: "una fila por teléfono", right: "Diseño relacional más flexible" }, + { left: "lista 'A,B,C' en una celda", right: "Valor multivaluado problemático" }, + ], + explanation: "1FN evita empaquetar colecciones dentro de una celda.", + }, + { + type: "quiz", + question: "¿Qué diseño escala mejor para cantidad variable de teléfonos?", + options: ["telefono1..telefono20", "Una tabla telefono_cliente", "Un TEXT con comas", "Copiar cliente"], + correctIndex: 1, + explanation: "La relación separada representa la multiplicidad.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "Cliente 1:N {{0}}", + blanks: [{ answer: "TelefonoCliente" }], + explanation: "La repetición se transforma en filas.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/03-segunda-forma-normal.ts b/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/03-segunda-forma-normal.ts new file mode 100644 index 0000000..4aee242 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/03-segunda-forma-normal.ts @@ -0,0 +1,40 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion03 = defineLesson({ + slug: "segunda-forma-normal", + title: "Dependencia de toda la clave", + description: "Separa atributos que dependen sólo de parte de una clave compuesta.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `2FN importa especialmente cuando la clave es compuesta. En \`detalle_venta(venta_id, producto_id, producto_nombre, cantidad)\`, \`producto_nombre\` depende sólo de \`producto_id\`, no del par completo. + +Ese hecho pertenece a Producto; \`cantidad\` sí describe la combinación venta-producto.`, + }, + { + type: "matching", + pairs: [ + { left: "cantidad", right: "Depende de venta_id + producto_id" }, + { left: "producto_nombre", right: "Depende sólo de producto_id" }, + { left: "fecha_venta", right: "Depende sólo de venta_id" }, + ], + explanation: "Los hechos parciales deben moverse a su entidad.", + }, + { + type: "quiz", + question: "¿Qué atributo viola 2FN en detalle(venta_id,producto_id,producto_nombre,cantidad)?", + options: ["cantidad", "producto_nombre", "ambas PK", "ninguno"], + correctIndex: 1, + explanation: "Nombre es hecho del producto.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "Atributo no clave debe depender de {{0}} la clave compuesta.", + blanks: [{ answer: "toda" }], + explanation: "Esa es la intuición de 2FN.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/04-tercera-forma-normal.ts b/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/04-tercera-forma-normal.ts new file mode 100644 index 0000000..b469224 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/04-tercera-forma-normal.ts @@ -0,0 +1,40 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion04 = defineLesson({ + slug: "tercera-forma-normal", + title: "Dependencias transitivas", + description: "Separa atributos no clave que dependen de otros atributos no clave.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `En \`empleado(id, depto_id, depto_nombre)\`, \`depto_nombre\` depende de \`depto_id\`, no directamente del empleado. Es una dependencia transitiva a través de otro atributo no clave. + +Separar Departamento evita repetir su nombre por cada empleado.`, + }, + { + type: "matching", + pairs: [ + { left: "empleado.id", right: "Clave de empleado" }, + { left: "depto_id", right: "FK al departamento" }, + { left: "depto_nombre", right: "Hecho del departamento" }, + ], + explanation: "3FN busca que los atributos no clave describan la clave, no otros atributos no clave.", + }, + { + type: "quiz", + question: "¿Dónde debe almacenarse `depto_nombre`?", + options: ["Empleado", "Departamento", "En ambos", "En ninguna"], + correctIndex: 1, + explanation: "Es propiedad del departamento.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "dependencia transitiva → separar {{0}}", + blanks: [{ answer: "entidad" }], + explanation: "La tabla resultante representa hechos más claros.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/05-normalizar-caso.ts b/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/05-normalizar-caso.ts new file mode 100644 index 0000000..34aac22 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/05-normalizacion/lessons/05-normalizar-caso.ts @@ -0,0 +1,58 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion05 = defineLesson({ + slug: "normalizar-caso", + title: "Normalizar una tabla de tickets", + description: "Aplica 1FN→2FN→3FN a un caso completo.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Tabla inicial imaginaria: + +\`ticket(folio, cliente_nombre, cliente_ciudad, tecnico1, tecnico2, servicio_codigo, servicio_nombre, costo)\` + +Problemas: técnicos repetidos como columnas, datos de cliente repetidos, nombre de servicio dependiente de código. + +Resultado conceptual: +- cliente +- ticket +- tecnico +- asignacion +- servicio +- ticket_servicio si hay multiplicidad + +Normalizar es razonar sobre dependencias, no aplicar una receta ciega.`, + }, + { + type: "code_completion", + prompt: "Ordena correctamente.", + lines: [ + "Identificar clave(s)", + "Eliminar grupos repetidos (1FN)", + "Eliminar dependencias parciales (2FN)", + "Eliminar dependencias transitivas (3FN)", + "Revisar consultas/reglas", + ], + explanation: "El orden ayuda a explicar cada separación.", + }, + { + type: "quiz", + question: "Si `servicio_nombre` depende de `servicio_codigo`, ¿dónde debe vivir?", + options: ["Ticket", "Servicio", "Cliente", "Asignacion"], + correctIndex: 1, + explanation: "Ambos describen el mismo hecho Servicio.", + }, + { + type: "matching", + pairs: [ + { left: "cliente_nombre/ciudad", right: "Cliente" }, + { left: "folio/estado", right: "Ticket" }, + { left: "tecnico_nombre", right: "Tecnico" }, + { left: "horas por técnico-ticket", right: "Asignacion" }, + ], + explanation: "Cada tabla agrupa hechos que dependen de su clave.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/05-normalizacion/practice.ts b/prisma/content/courses/bases-de-datos/units/05-normalizacion/practice.ts new file mode 100644 index 0000000..32cab7b --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/05-normalizacion/practice.ts @@ -0,0 +1,90 @@ +import type { PracticeExerciseDefinition } from "../../../../exercises/types"; + +export const practice = [ + { + slug: "bd1-normalizacion-separar-cliente", + title: "Evitar repetición", + description: "Con esquema ya normalizado, consulta ticket y nombre del cliente mediante JOIN.", + prompt: "Con esquema ya normalizado, consulta ticket y nombre del cliente mediante JOIN.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT ticket.id,cliente.nombre FROM ticket JOIN cliente ON cliente.id=ticket.cliente_id ORDER BY ticket.id;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL'); +CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `10|Ana +11|Ana +12|Luis +13|Mara`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT); CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER); INSERT INTO cliente VALUES(5,'Eva'); INSERT INTO ticket VALUES(2,5),(9,5);`, + expectedStdout: `2|Eva +9|Eva`, + visible: false, + }, + ], + }, + { + slug: "bd1-normalizacion-telefonos", + title: "Modelo 1:N", + description: + "Cuenta cuántos teléfonos tiene cada cliente; devuelve id|cantidad incluyendo sólo clientes con teléfonos.", + prompt: + "Cuenta cuántos teléfonos tiene cada cliente; devuelve id|cantidad incluyendo sólo clientes con teléfonos.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT cliente_id,COUNT(*) FROM telefono_cliente GROUP BY cliente_id ORDER BY cliente_id;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE telefono_cliente(cliente_id INTEGER,telefono TEXT); INSERT INTO telefono_cliente VALUES(1,'a'),(1,'b'),(2,'c');`, + expectedStdout: `1|2 +2|1`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE telefono_cliente(cliente_id INTEGER,telefono TEXT); INSERT INTO telefono_cliente VALUES(9,'x');`, + expectedStdout: `9|1`, + visible: false, + }, + ], + }, + { + slug: "bd1-normalizacion-departamento", + title: "Eliminar dependencia transitiva", + description: "Consulta empleado|departamento usando tablas separadas.", + prompt: "Consulta empleado|departamento usando tablas separadas.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT empleado.nombre,departamento.nombre FROM empleado JOIN departamento ON departamento.id=empleado.depto_id ORDER BY empleado.id;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE departamento(id INTEGER,nombre TEXT); CREATE TABLE empleado(id INTEGER,nombre TEXT,depto_id INTEGER); INSERT INTO departamento VALUES(1,'TI'),(2,'Ventas'); INSERT INTO empleado VALUES(1,'Ana',1),(2,'Luis',2);`, + expectedStdout: `Ana|TI +Luis|Ventas`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE departamento(id INTEGER,nombre TEXT); CREATE TABLE empleado(id INTEGER,nombre TEXT,depto_id INTEGER); INSERT INTO departamento VALUES(8,'Soporte'); INSERT INTO empleado VALUES(4,'Mara',8);`, + expectedStdout: `Mara|Soporte`, + visible: false, + }, + ], + }, +] satisfies PracticeExerciseDefinition[]; diff --git a/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/index.ts b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/index.ts new file mode 100644 index 0000000..3b220ef --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/index.ts @@ -0,0 +1,19 @@ +import { defineUnit } from "../../../../authoring"; + +import { leccion01 } from "./lessons/01-seleccion-proyeccion"; +import { leccion02 } from "./lessons/02-union-diferencia"; +import { leccion03 } from "./lessons/03-producto-y-join"; +import { leccion04 } from "./lessons/04-composicion-consulta"; + +import { practice } from "./practice"; + +export const bd1AlgebraRelacional = defineUnit({ + slug: "bd1-06-algebra-relacional", + title: "Álgebra relacional", + description: + "Conecta selección, proyección, unión, diferencia y join con las cláusulas SQL que los expresan.", + icon: "∑", + published: true, + lessons: [leccion01, leccion02, leccion03, leccion04], + practice, +}); diff --git a/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/01-seleccion-proyeccion.ts b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/01-seleccion-proyeccion.ts new file mode 100644 index 0000000..c015ba6 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/01-seleccion-proyeccion.ts @@ -0,0 +1,44 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion01 = defineLesson({ + slug: "seleccion-proyeccion", + title: "Selección y proyección", + description: "Distingue filtrar filas de elegir columnas y conéctalo con SQL.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `En álgebra relacional, **selección** conserva filas que satisfacen un predicado. **Proyección** conserva atributos/columnas. + +SQL aproxima estas operaciones con \`WHERE\` y la lista de \`SELECT\` respectivamente.`, + }, + { + type: "matching", + pairs: [ + { left: "σ estado='ABIERTO'", right: "Selección" }, + { left: "π nombre,ciudad", right: "Proyección" }, + { left: "WHERE estado='ABIERTO'", right: "SQL de selección" }, + { left: "SELECT nombre,ciudad", right: "SQL de proyección" }, + ], + explanation: "El álgebra describe operación; SQL la expresa.", + }, + { + type: "quiz", + question: "¿Qué operación reduce filas?", + options: ["Proyección", "Selección", "Producto cartesiano", "Renombrado"], + correctIndex: 1, + explanation: "Selección aplica un predicado a filas.", + }, + { + type: "code_example", + code: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL'); +SELECT nombre FROM cliente WHERE ciudad='GDL' ORDER BY id;`, + explanation: "WHERE selecciona; SELECT nombre proyecta.", + runnable: true, + expectedOutput: `Ana +Mara`, + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/02-union-diferencia.ts b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/02-union-diferencia.ts new file mode 100644 index 0000000..82be29f --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/02-union-diferencia.ts @@ -0,0 +1,45 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion02 = defineLesson({ + slug: "union-diferencia", + title: "Unión y diferencia", + description: "Entiende compatibilidad de relaciones y resultados de conjuntos.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Unión combina tuplas compatibles. Diferencia conserva tuplas de una relación que no están en otra. Requieren estructuras compatibles en los atributos comparados. + +SQLite ofrece \`UNION\` y \`EXCEPT\` para practicar estas ideas.`, + }, + { + type: "code_example", + code: `CREATE TABLE a(x TEXT); CREATE TABLE b(x TEXT); +INSERT INTO a VALUES('A'),('B'); INSERT INTO b VALUES('B'),('C'); +SELECT x FROM a UNION SELECT x FROM b ORDER BY x;`, + explanation: "UNION elimina duplicados por semántica de conjunto.", + runnable: true, + expectedOutput: `A +B +C`, + }, + { + type: "quiz", + question: "¿Qué devuelve A − B?", + options: ["Todo A y B", "Elementos de A que no están en B", "Sólo intersección", "Producto"], + correctIndex: 1, + explanation: "Diferencia es direccional.", + }, + { + type: "matching", + pairs: [ + { left: "UNION", right: "Unión" }, + { left: "EXCEPT", right: "Diferencia en SQLite" }, + { left: "A ∪ B", right: "Elementos de ambos" }, + { left: "A − B", right: "Sólo los de A que faltan en B" }, + ], + explanation: "Relaciona notación con SQL.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/03-producto-y-join.ts b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/03-producto-y-join.ts new file mode 100644 index 0000000..8ce61ab --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/03-producto-y-join.ts @@ -0,0 +1,47 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion03 = defineLesson({ + slug: "producto-y-join", + title: "Producto cartesiano y join", + description: "Entiende que JOIN restringe combinaciones mediante una condición.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `El producto cartesiano combina cada fila de A con cada fila de B. Casi nunca es el resultado final deseado; un join agrega una condición que conserva combinaciones relacionadas. + +Pensar así evita joins "mágicos": primero existen pares posibles; \`ON cliente.id=ticket.cliente_id\` define cuáles representan hechos válidos.`, + }, + { + type: "matching", + pairs: [ + { left: "A × B", right: "Todas las combinaciones" }, + { left: "JOIN ... ON", right: "Combinaciones que cumplen condición" }, + { left: "FK", right: "Vínculo semántico que suele guiar el join" }, + ], + explanation: "JOIN puede verse como producto + selección.", + }, + { + type: "quiz", + question: "Olvidas `ON` entre 3 clientes y 4 tickets. ¿Cuántas combinaciones produce el cartesiano?", + options: ["4", "7", "12", "1"], + correctIndex: 2, + explanation: "3×4=12.", + }, + { + type: "code_example", + code: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL'); +CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300); +SELECT cliente.nombre,ticket.id FROM cliente JOIN ticket ON ticket.cliente_id=cliente.id ORDER BY ticket.id;`, + explanation: "El join reconstruye información normalizada.", + runnable: true, + expectedOutput: `Ana|10 +Ana|11 +Luis|12 +Mara|13`, + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/04-composicion-consulta.ts b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/04-composicion-consulta.ts new file mode 100644 index 0000000..803e508 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/lessons/04-composicion-consulta.ts @@ -0,0 +1,60 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion04 = defineLesson({ + slug: "composicion-consulta", + title: "Componer operaciones", + description: "Traduce una pregunta en secuencia de operaciones relacionales.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Pregunta: "nombres de clientes de GDL con tickets cerrados". + +Razonamiento: +1. seleccionar clientes GDL; +2. seleccionar tickets cerrados; +3. unir por cliente_id; +4. proyectar nombre; +5. eliminar duplicados si la pregunta pide clientes únicos. + +El SQL final es más fácil cuando primero entiendes la transformación.`, + }, + { + type: "code_completion", + prompt: "Ordena correctamente.", + lines: [ + "Seleccionar clientes GDL", + "Seleccionar tickets CERRADO", + "Join Cliente↔Ticket", + "Proyectar nombre", + "Eliminar duplicados si corresponde", + ], + explanation: "La consulta es una composición de operaciones.", + }, + { + type: "quiz", + question: "¿Por qué `DISTINCT` puede ser necesario al proyectar clientes con muchos tickets?", + options: [ + "Porque JOIN no funciona", + "Porque un cliente puede aparecer varias veces", + "Porque WHERE duplica tablas", + "Porque PK falla", + ], + correctIndex: 1, + explanation: "La multiplicidad del join puede repetir un nombre.", + }, + { + type: "code_example", + code: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL'); +CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300); +SELECT DISTINCT cliente.nombre FROM cliente JOIN ticket ON ticket.cliente_id=cliente.id WHERE cliente.ciudad='GDL' AND ticket.estado='CERRADO' ORDER BY cliente.nombre;`, + explanation: "La consulta compone selección, join y proyección.", + runnable: true, + expectedOutput: `Ana +Mara`, + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/practice.ts b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/practice.ts new file mode 100644 index 0000000..0a3bbed --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/06-algebra-relacional/practice.ts @@ -0,0 +1,90 @@ +import type { PracticeExerciseDefinition } from "../../../../exercises/types"; + +export const practice = [ + { + slug: "bd1-algebra-seleccion", + title: "Selección", + description: "Devuelve ids de tickets cerrados, ordenados.", + prompt: "Devuelve ids de tickets cerrados, ordenados.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT id FROM ticket WHERE estado='CERRADO' ORDER BY id;`, + difficulty: "easy", + xpReward: 20, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `11 +13`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE ticket(id INTEGER,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO ticket VALUES(5,1,'ABIERTO',0),(2,1,'CERRADO',0);`, + expectedStdout: `2`, + visible: false, + }, + ], + }, + { + slug: "bd1-algebra-union", + title: "Unión", + description: "Devuelve todos los códigos de dos tablas sin duplicados y ordenados.", + prompt: "Devuelve todos los códigos de dos tablas sin duplicados y ordenados.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT codigo FROM a UNION SELECT codigo FROM b ORDER BY codigo;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE a(codigo TEXT); CREATE TABLE b(codigo TEXT); INSERT INTO a VALUES('A'),('B'); INSERT INTO b VALUES('B'),('C');`, + expectedStdout: `A +B +C`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE a(codigo TEXT); CREATE TABLE b(codigo TEXT); INSERT INTO a VALUES('X'); INSERT INTO b VALUES('Y');`, + expectedStdout: `X +Y`, + visible: false, + }, + ], + }, + { + slug: "bd1-algebra-join", + title: "Join", + description: "Devuelve cliente|ticket para todos los tickets.", + prompt: "Devuelve cliente|ticket para todos los tickets.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT cliente.nombre,ticket.id FROM cliente JOIN ticket ON ticket.cliente_id=cliente.id ORDER BY ticket.id;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL'); +CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `Ana|10 +Ana|11 +Luis|12 +Mara|13`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE cliente(id INTEGER,nombre TEXT,ciudad TEXT); CREATE TABLE ticket(id INTEGER,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO cliente VALUES(4,'Eva','X'); INSERT INTO ticket VALUES(7,4,'A',0);`, + expectedStdout: `Eva|7`, + visible: false, + }, + ], + }, +] satisfies PracticeExerciseDefinition[]; diff --git a/prisma/content/courses/bases-de-datos/units/07-ddl/index.ts b/prisma/content/courses/bases-de-datos/units/07-ddl/index.ts new file mode 100644 index 0000000..b71ce1e --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/07-ddl/index.ts @@ -0,0 +1,20 @@ +import { defineUnit } from "../../../../authoring"; + +import { leccion01 } from "./lessons/01-create-table"; +import { leccion02 } from "./lessons/02-pk-unique-check"; +import { leccion03 } from "./lessons/03-foreign-key-ddl"; +import { leccion04 } from "./lessons/04-alter-drop"; +import { leccion05 } from "./lessons/05-schema-completo"; + +import { practice } from "./practice"; + +export const bd1Ddl = defineUnit({ + slug: "bd1-07-ddl", + title: "DDL: construir la estructura", + description: + "Define tablas, tipos y restricciones (PK, FK, UNIQUE, CHECK) y ensambla el esquema del caso.", + icon: "🏗️", + published: true, + lessons: [leccion01, leccion02, leccion03, leccion04, leccion05], + practice, +}); diff --git a/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/01-create-table.ts b/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/01-create-table.ts new file mode 100644 index 0000000..4347188 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/01-create-table.ts @@ -0,0 +1,44 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion01 = defineLesson({ + slug: "create-table", + title: "CREATE TABLE y tipos", + description: "Crea tablas pequeñas con tipos y restricciones básicas.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `DDL define la estructura. En SQLite usaremos \`INTEGER\`, \`TEXT\`, \`REAL\` y restricciones portables. + +El objetivo no es memorizar todos los tipos de cada SGBD, sino elegir representaciones coherentes y declarar reglas básicas.`, + }, + { + type: "code_example", + code: `CREATE TABLE tecnico( + id INTEGER PRIMARY KEY, + nombre TEXT NOT NULL, + activo INTEGER NOT NULL CHECK(activo IN (0,1)) +); +INSERT INTO tecnico VALUES(1,'Ana',1); +SELECT id,nombre,activo FROM tecnico;`, + explanation: "La tabla declara identidad, obligatoriedad y dominio simple.", + runnable: true, + expectedOutput: `1|Ana|1`, + }, + { + type: "quiz", + question: "¿Qué comando define una tabla?", + options: ["INSERT", "CREATE TABLE", "SELECT", "UPDATE"], + correctIndex: 1, + explanation: "CREATE TABLE pertenece a DDL.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "CREATE TABLE tecnico(id INTEGER {{0}}, nombre TEXT {{1}});", + blanks: [{ answer: "PRIMARY KEY" }, { answer: "NOT NULL" }], + explanation: "PK identifica; NOT NULL obliga valor.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/02-pk-unique-check.ts b/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/02-pk-unique-check.ts new file mode 100644 index 0000000..67b5d9c --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/02-pk-unique-check.ts @@ -0,0 +1,52 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion02 = defineLesson({ + slug: "pk-unique-check", + title: "Restricciones en DDL", + description: "Aplica PK, UNIQUE y CHECK en el esquema.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Las restricciones hacen que datos inválidos fallen temprano. + +- PRIMARY KEY: identidad. +- UNIQUE: evita duplicados. +- CHECK: condición local. +- NOT NULL: obligatoriedad. + +Decláralas sólo cuando responden a una regla real.`, + }, + { + type: "matching", + pairs: [ + { left: "PRIMARY KEY", right: "Identidad" }, + { left: "UNIQUE", right: "Unicidad alternativa" }, + { left: "CHECK", right: "Predicado local" }, + { left: "NOT NULL", right: "Obligatoriedad" }, + ], + explanation: "Cada restricción expresa una clase de regla.", + }, + { + type: "quiz", + question: "Folio debe ser único pero `id` ya es PK. ¿Qué usar?", + options: ["UNIQUE en folio", "Otra PK", "ORDER BY", "DELETE"], + correctIndex: 0, + explanation: "UNIQUE expresa una clave candidata no elegida como PK.", + }, + { + type: "code_example", + code: `CREATE TABLE ticket( + id INTEGER PRIMARY KEY, + folio TEXT NOT NULL UNIQUE, + costo INTEGER NOT NULL CHECK(costo>=0) +); +INSERT INTO ticket VALUES(1,'T-1',0); +SELECT folio,costo FROM ticket;`, + explanation: "El SGBD protege dos invariantes.", + runnable: true, + expectedOutput: `T-1|0`, + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/03-foreign-key-ddl.ts b/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/03-foreign-key-ddl.ts new file mode 100644 index 0000000..693393d --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/03-foreign-key-ddl.ts @@ -0,0 +1,48 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion03 = defineLesson({ + slug: "foreign-key-ddl", + title: "FOREIGN KEY en DDL", + description: "Declara referencias entre tablas.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Una FK documenta y protege un vínculo. En SQLite debemos activar \`PRAGMA foreign_keys=ON\` para enforcement en una conexión. + +El diseño sigue siendo relacional aunque el motor tenga detalles operativos propios.`, + }, + { + type: "code_example", + code: `PRAGMA foreign_keys=ON; +CREATE TABLE cliente(id INTEGER PRIMARY KEY); +CREATE TABLE ticket( + id INTEGER PRIMARY KEY, + cliente_id INTEGER NOT NULL, + FOREIGN KEY(cliente_id) REFERENCES cliente(id) +); +INSERT INTO cliente VALUES(1); +INSERT INTO ticket VALUES(10,1); +SELECT id,cliente_id FROM ticket;`, + explanation: "La fila de ticket referencia un cliente existente.", + runnable: true, + expectedOutput: `10|1`, + }, + { + type: "quiz", + question: + "¿Qué pasa conceptualmente si cliente_id referencia un cliente inexistente y la FK se aplica?", + options: ["Se acepta siempre", "Debe rechazarse", "Se crea cliente", "Se vuelve NULL"], + correctIndex: 1, + explanation: "La integridad referencial impide referencias huérfanas.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "FOREIGN KEY({{0}}) REFERENCES cliente({{1}})", + blanks: [{ answer: "cliente_id" }, { answer: "id" }], + explanation: "Declara origen y destino.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/04-alter-drop.ts b/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/04-alter-drop.ts new file mode 100644 index 0000000..35967f7 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/04-alter-drop.ts @@ -0,0 +1,44 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion04 = defineLesson({ + slug: "alter-drop", + title: "Cambiar y eliminar estructura con cuidado", + description: "Distingue evolución de esquema de manipulación de datos.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `\`ALTER TABLE\` cambia estructura; \`DROP TABLE\` elimina la tabla. Son operaciones DDL y pueden tener impacto destructivo. + +En BD I basta comprender el riesgo y usar cambios simples. Migraciones complejas y operación de producción quedan fuera.`, + }, + { + type: "matching", + pairs: [ + { left: "ALTER TABLE", right: "Modificar estructura" }, + { left: "DROP TABLE", right: "Eliminar estructura" }, + { left: "DELETE", right: "Eliminar filas" }, + { left: "UPDATE", right: "Modificar filas" }, + ], + explanation: "DDL y DML afectan niveles distintos.", + }, + { + type: "quiz", + question: "¿Qué elimina la tabla completa, no sólo sus filas?", + options: ["DELETE FROM tabla", "DROP TABLE tabla", "UPDATE tabla", "SELECT"], + correctIndex: 1, + explanation: "DROP elimina el objeto del esquema.", + }, + { + type: "code_example", + code: `CREATE TABLE ejemplo(id INTEGER PRIMARY KEY); +ALTER TABLE ejemplo ADD COLUMN nombre TEXT; +SELECT name FROM pragma_table_info('ejemplo') ORDER BY cid;`, + explanation: "PRAGMA permite observar el cambio de esquema.", + runnable: true, + expectedOutput: `id +nombre`, + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/05-schema-completo.ts b/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/05-schema-completo.ts new file mode 100644 index 0000000..a9e35e7 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/07-ddl/lessons/05-schema-completo.ts @@ -0,0 +1,56 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion05 = defineLesson({ + slug: "schema-completo", + title: "Construir el esquema del caso", + description: "Ensambla Cliente, Ticket, Técnico y Asignación en DDL coherente.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `El esquema parcial integrador debe respetar el modelo: + +- \`cliente\` +- \`ticket(cliente_id FK)\` +- \`tecnico\` +- \`asignacion(ticket_id, tecnico_id, horas)\` con PK compuesta + +El orden de creación importa cuando queremos razonar claramente sobre dependencias.`, + }, + { + type: "code_completion", + prompt: "Ordena correctamente.", + lines: [ + "Crear cliente", + "Crear tecnico", + "Crear ticket con FK cliente", + "Crear asignacion con FKs ticket/tecnico", + "Revisar esquema", + ], + explanation: "Primero las tablas referenciadas; luego las dependientes.", + }, + { + type: "quiz", + question: "¿Qué tabla contiene dos FKs?", + options: ["cliente", "tecnico", "asignacion", "ninguna"], + correctIndex: 2, + explanation: "La asociativa referencia ambos extremos.", + }, + { + type: "code_example", + code: `PRAGMA foreign_keys=ON; +CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT NOT NULL); +CREATE TABLE tecnico(id INTEGER PRIMARY KEY,nombre TEXT NOT NULL); +CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER NOT NULL,FOREIGN KEY(cliente_id) REFERENCES cliente(id)); +CREATE TABLE asignacion(ticket_id INTEGER,tecnico_id INTEGER,horas INTEGER NOT NULL,PRIMARY KEY(ticket_id,tecnico_id),FOREIGN KEY(ticket_id) REFERENCES ticket(id),FOREIGN KEY(tecnico_id) REFERENCES tecnico(id)); +SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;`, + explanation: "El resultado verifica que las cuatro relaciones existen.", + runnable: true, + expectedOutput: `asignacion +cliente +tecnico +ticket`, + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/07-ddl/practice.ts b/prisma/content/courses/bases-de-datos/units/07-ddl/practice.ts new file mode 100644 index 0000000..da46b3f --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/07-ddl/practice.ts @@ -0,0 +1,139 @@ +import type { PracticeExerciseDefinition } from "../../../../exercises/types"; + +export const practice = [ + { + slug: "bd1-ddl-cliente", + title: "Tabla cliente", + description: "Crea cliente con id PK y nombre NOT NULL; termina consultando name de PRAGMA.", + prompt: "Crea cliente con id PK y nombre NOT NULL; termina consultando `name` de PRAGMA.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT NOT NULL); +SELECT name FROM pragma_table_info('cliente') ORDER BY cid;`, + difficulty: "medium", + xpReward: 30, + testCases: [ + { + description: "estructura", + stdin: ``, + expectedStdout: `id +nombre`, + visible: true, + }, + { + description: "ejecución independiente", + stdin: ``, + expectedStdout: `id +nombre`, + visible: false, + }, + ], + }, + { + slug: "bd1-ddl-ticket-unique", + title: "Folio único", + description: + "Crea ticket(id PK, folio TEXT NOT NULL UNIQUE) y cuenta los índices únicos de la tabla.", + prompt: + "Crea ticket(id PK, folio TEXT NOT NULL UNIQUE) y termina `SELECT COUNT(*) FROM pragma_index_list('ticket') WHERE \"unique\"=1;`.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `CREATE TABLE ticket(id INTEGER PRIMARY KEY,folio TEXT NOT NULL UNIQUE); +SELECT COUNT(*) FROM pragma_index_list('ticket') WHERE "unique"=1;`, + difficulty: "medium", + xpReward: 30, + testCases: [ + { + description: "estructura", + stdin: ``, + expectedStdout: `1`, + visible: true, + }, + { + description: "ejecución independiente", + stdin: ``, + expectedStdout: `1`, + visible: false, + }, + ], + }, + { + slug: "bd1-ddl-check", + title: "Costo no negativo", + description: "Crea ticket con costo INTEGER NOT NULL CHECK(costo>=0), inserta costo 10 y selecciónalo.", + prompt: + "Crea ticket con `costo INTEGER NOT NULL CHECK(costo>=0)`, inserta costo 10 y selecciónalo.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `CREATE TABLE ticket(id INTEGER PRIMARY KEY,costo INTEGER NOT NULL CHECK(costo>=0)); INSERT INTO ticket VALUES(1,10); SELECT costo FROM ticket;`, + difficulty: "medium", + xpReward: 30, + testCases: [ + { + description: "estructura", + stdin: ``, + expectedStdout: `10`, + visible: true, + }, + { + description: "ejecución independiente", + stdin: ``, + expectedStdout: `10`, + visible: false, + }, + ], + }, + { + slug: "bd1-ddl-fk", + title: "FK a cliente", + description: "Crea cliente y ticket con FK; termina consultando tabla destino en PRAGMA.", + prompt: "Crea cliente y ticket con FK; termina consultando tabla destino en PRAGMA.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `CREATE TABLE cliente(id INTEGER PRIMARY KEY); CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER NOT NULL,FOREIGN KEY(cliente_id) REFERENCES cliente(id)); SELECT "table" FROM pragma_foreign_key_list('ticket');`, + difficulty: "medium", + xpReward: 30, + testCases: [ + { + description: "estructura", + stdin: ``, + expectedStdout: `cliente`, + visible: true, + }, + { + description: "ejecución independiente", + stdin: ``, + expectedStdout: `cliente`, + visible: false, + }, + ], + }, + { + slug: "bd1-ddl-asignacion", + title: "PK compuesta", + description: "Crea asignacion con PK(ticket_id,tecnico_id); consulta valores pk de ambas columnas ordenados.", + prompt: + "Crea asignacion con PK(ticket_id,tecnico_id); consulta valores `pk` de ambas columnas ordenados.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `CREATE TABLE asignacion(ticket_id INTEGER,tecnico_id INTEGER,horas INTEGER,PRIMARY KEY(ticket_id,tecnico_id)); SELECT pk FROM pragma_table_info('asignacion') WHERE name IN ('ticket_id','tecnico_id') ORDER BY pk;`, + difficulty: "medium", + xpReward: 30, + testCases: [ + { + description: "estructura", + stdin: ``, + expectedStdout: `1 +2`, + visible: true, + }, + { + description: "ejecución independiente", + stdin: ``, + expectedStdout: `1 +2`, + visible: false, + }, + ], + }, +] satisfies PracticeExerciseDefinition[]; diff --git a/prisma/content/courses/bases-de-datos/units/08-dml/index.ts b/prisma/content/courses/bases-de-datos/units/08-dml/index.ts new file mode 100644 index 0000000..3986271 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/08-dml/index.ts @@ -0,0 +1,18 @@ +import { defineUnit } from "../../../../authoring"; + +import { leccion01 } from "./lessons/01-insert"; +import { leccion02 } from "./lessons/02-update"; +import { leccion03 } from "./lessons/03-delete"; +import { leccion04 } from "./lessons/04-crud-relacional"; + +import { practice } from "./practice"; + +export const bd1Dml = defineUnit({ + slug: "bd1-08-dml", + title: "DML: insertar, modificar y eliminar", + description: "Inserta, actualiza y elimina datos respetando restricciones e integridad referencial.", + icon: "✍️", + published: true, + lessons: [leccion01, leccion02, leccion03, leccion04], + practice, +}); diff --git a/prisma/content/courses/bases-de-datos/units/08-dml/lessons/01-insert.ts b/prisma/content/courses/bases-de-datos/units/08-dml/lessons/01-insert.ts new file mode 100644 index 0000000..db8266b --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/08-dml/lessons/01-insert.ts @@ -0,0 +1,40 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion01 = defineLesson({ + slug: "insert", + title: "INSERT y filas válidas", + description: "Inserta datos respetando esquema y restricciones.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `DML manipula datos. \`INSERT\` agrega filas. El orden de columnas explícito reduce errores y hace el script más legible. + +Una inserción debe cumplir PK, FK, NOT NULL, UNIQUE y CHECK declarados.`, + }, + { + type: "code_example", + code: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT NOT NULL,ciudad TEXT NOT NULL); +INSERT INTO cliente(id,nombre,ciudad) VALUES(1,'Ana','GDL'); +SELECT id,nombre,ciudad FROM cliente;`, + explanation: "La lista de columnas hace explícita la correspondencia.", + runnable: true, + expectedOutput: `1|Ana|GDL`, + }, + { + type: "quiz", + question: "¿Qué ocurre si insertas una segunda fila con la misma PK?", + options: ["Debe rechazarse", "Se fusiona", "Se ordena", "Se vuelve NULL"], + correctIndex: 0, + explanation: "La PK es única.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "INSERT INTO cliente({{0}},nombre) VALUES(1,'Ana');", + blanks: [{ answer: "id" }], + explanation: "La lista identifica columnas destino.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/08-dml/lessons/02-update.ts b/prisma/content/courses/bases-de-datos/units/08-dml/lessons/02-update.ts new file mode 100644 index 0000000..07fc522 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/08-dml/lessons/02-update.ts @@ -0,0 +1,42 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion02 = defineLesson({ + slug: "update", + title: "UPDATE con condición", + description: "Modifica sólo las filas pretendidas.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `\`UPDATE\` sin \`WHERE\` modifica todas las filas. Antes de ejecutarlo, formula qué conjunto debe cambiar y verifica el predicado. + +La operación correcta no es sólo sintaxis: es seleccionar exactamente el alcance.`, + }, + { + type: "code_example", + code: `CREATE TABLE ticket(id INTEGER PRIMARY KEY,estado TEXT); +INSERT INTO ticket VALUES(1,'ABIERTO'),(2,'ABIERTO'); +UPDATE ticket SET estado='CERRADO' WHERE id=2; +SELECT id,estado FROM ticket ORDER BY id;`, + explanation: "WHERE limita el cambio.", + runnable: true, + expectedOutput: `1|ABIERTO +2|CERRADO`, + }, + { + type: "quiz", + question: "¿Cuál es el mayor riesgo de olvidar WHERE?", + options: ["No compila", "Modificar todas las filas", "Sólo cambia PK", "Convierte a SELECT"], + correctIndex: 1, + explanation: "El alcance se vuelve toda la tabla.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "UPDATE ticket SET estado='CERRADO' {{0}} id=2;", + blanks: [{ answer: "WHERE" }], + explanation: "La condición delimita.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/08-dml/lessons/03-delete.ts b/prisma/content/courses/bases-de-datos/units/08-dml/lessons/03-delete.ts new file mode 100644 index 0000000..cde4058 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/08-dml/lessons/03-delete.ts @@ -0,0 +1,44 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion03 = defineLesson({ + slug: "delete", + title: "DELETE y alcance", + description: "Elimina filas específicas sin confundirlo con DROP.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `\`DELETE FROM tabla WHERE ...\` elimina filas. \`DROP TABLE\` elimina la estructura completa. + +Eliminar datos puede afectar integridad referencial; el modelo debe decidir qué relaciones impiden borrar o qué política se usa.`, + }, + { + type: "matching", + pairs: [ + { left: "DELETE", right: "Filas" }, + { left: "DROP TABLE", right: "Estructura" }, + { left: "WHERE", right: "Alcance" }, + { left: "FK", right: "Puede bloquear borrado referenciado" }, + ], + explanation: "Distingue nivel de operación.", + }, + { + type: "quiz", + question: "¿Qué comando conserva la tabla pero elimina filas?", + options: ["DROP", "DELETE", "CREATE", "ALTER"], + correctIndex: 1, + explanation: "DELETE es DML.", + }, + { + type: "code_example", + code: `CREATE TABLE ticket(id INTEGER PRIMARY KEY,estado TEXT); +INSERT INTO ticket VALUES(1,'CERRADO'),(2,'ABIERTO'),(3,'CERRADO'); +DELETE FROM ticket WHERE estado='CERRADO'; +SELECT id FROM ticket ORDER BY id;`, + explanation: "Sólo queda el ticket abierto.", + runnable: true, + expectedOutput: `2`, + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/08-dml/lessons/04-crud-relacional.ts b/prisma/content/courses/bases-de-datos/units/08-dml/lessons/04-crud-relacional.ts new file mode 100644 index 0000000..4af8dfc --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/08-dml/lessons/04-crud-relacional.ts @@ -0,0 +1,48 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion04 = defineLesson({ + slug: "crud-relacional", + title: "CRUD sobre datos relacionados", + description: "Combina INSERT/UPDATE/DELETE sin romper referencias.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `CRUD suele resumir Create, Read, Update, Delete. En una base relacional las operaciones no viven aisladas: insertar un ticket requiere cliente válido; borrar cliente puede estar bloqueado si conserva tickets. + +La integridad guía el orden de operaciones.`, + }, + { + type: "code_completion", + prompt: "Ordena correctamente.", + lines: [ + "Insertar cliente", + "Insertar ticket que lo referencia", + "Consultar ticket", + "Actualizar estado", + "Eliminar según reglas", + ], + explanation: "Primero deben existir las filas referenciadas.", + }, + { + type: "quiz", + question: "¿Qué operación del acrónimo CRUD corresponde a SELECT?", + options: ["Create", "Read", "Update", "Delete"], + correctIndex: 1, + explanation: "SELECT recupera/lee datos.", + }, + { + type: "code_example", + code: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT); +CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT); +INSERT INTO cliente VALUES(1,'Ana'); +INSERT INTO ticket VALUES(10,1,'ABIERTO'); +UPDATE ticket SET estado='CERRADO' WHERE id=10; +SELECT cliente.nombre,ticket.estado FROM cliente JOIN ticket ON ticket.cliente_id=cliente.id;`, + explanation: "La lectura final observa operaciones previas.", + runnable: true, + expectedOutput: `Ana|CERRADO`, + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/08-dml/practice.ts b/prisma/content/courses/bases-de-datos/units/08-dml/practice.ts new file mode 100644 index 0000000..c402ee4 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/08-dml/practice.ts @@ -0,0 +1,141 @@ +import type { PracticeExerciseDefinition } from "../../../../exercises/types"; + +export const practice = [ + { + slug: "bd1-dml-insert", + title: "Insertar cliente", + description: "Inserta id 4, nombre Eva, ciudad GDL y devuelve la fila.", + prompt: "Inserta id 4, nombre Eva, ciudad GDL y devuelve la fila.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `INSERT INTO cliente(id,nombre,ciudad) VALUES(4,'Eva','GDL'); SELECT id,nombre,ciudad FROM cliente WHERE id=4;`, + difficulty: "easy", + xpReward: 20, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL');`, + expectedStdout: `4|Eva|GDL`, + visible: true, + }, + { + description: "vacía", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT,ciudad TEXT);`, + expectedStdout: `4|Eva|GDL`, + visible: false, + }, + ], + }, + { + slug: "bd1-dml-update", + title: "Cerrar un ticket", + description: "Cambia a CERRADO el ticket con id 12 y devuelve id|estado.", + prompt: "Cambia a CERRADO el ticket con id 12 y devuelve id|estado.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `UPDATE ticket SET estado='CERRADO' WHERE id=12; SELECT id,estado FROM ticket WHERE id=12;`, + difficulty: "easy", + xpReward: 20, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `12|CERRADO`, + visible: true, + }, + { + description: "no tocar 99", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO ticket VALUES(12,9,'ABIERTO',0),(99,9,'ABIERTO',0);`, + expectedStdout: `12|CERRADO`, + visible: false, + }, + ], + }, + { + slug: "bd1-dml-delete", + title: "Eliminar sólo cerrados baratos", + description: "Elimina tickets CERRADO con costo < 400 y devuelve ids restantes.", + prompt: "Elimina tickets CERRADO con costo < 400 y devuelve ids restantes.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `DELETE FROM ticket WHERE estado='CERRADO' AND costo<400; SELECT id FROM ticket ORDER BY id;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `10 +11 +12`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE ticket(id INTEGER,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO ticket VALUES(1,1,'CERRADO',50),(2,1,'CERRADO',900),(3,1,'ABIERTO',1);`, + expectedStdout: `2 +3`, + visible: false, + }, + ], + }, + { + slug: "bd1-dml-aumento", + title: "Actualizar costo", + description: "Aumenta 50 a todos los tickets ABIERTOS y devuelve id|costo ordenado.", + prompt: "Aumenta 50 a todos los tickets ABIERTOS y devuelve id|costo ordenado.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `UPDATE ticket SET costo=costo+50 WHERE estado='ABIERTO'; SELECT id,costo FROM ticket ORDER BY id;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `10|250 +11|500 +12|150 +13|300`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE ticket(id INTEGER,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO ticket VALUES(1,1,'ABIERTO',0),(2,1,'CERRADO',10);`, + expectedStdout: `1|50 +2|10`, + visible: false, + }, + ], + }, + { + slug: "bd1-dml-crud", + title: "Secuencia CRUD", + description: "Inserta ticket 20 abierto, actualízalo a cerrado y devuelve estado final.", + prompt: "Inserta ticket 20 abierto, actualízalo a cerrado y devuelve estado final.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `INSERT INTO ticket(id,cliente_id,estado,costo) VALUES(20,1,'ABIERTO',0); UPDATE ticket SET estado='CERRADO' WHERE id=20; SELECT estado FROM ticket WHERE id=20;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `CERRADO`, + visible: true, + }, + { + description: "vacía", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT,costo INTEGER);`, + expectedStdout: `CERRADO`, + visible: false, + }, + ], + }, +] satisfies PracticeExerciseDefinition[]; diff --git a/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/index.ts b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/index.ts new file mode 100644 index 0000000..d0df49e --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/index.ts @@ -0,0 +1,21 @@ +import { defineUnit } from "../../../../authoring"; + +import { leccion01 } from "./lessons/01-select-where-order"; +import { leccion02 } from "./lessons/02-join"; +import { leccion03 } from "./lessons/03-agregados"; +import { leccion04 } from "./lessons/04-group-by"; +import { leccion05 } from "./lessons/05-having"; +import { leccion06 } from "./lessons/06-reporte-negocio"; + +import { practice } from "./practice"; + +export const bd1ConsultasReportes = defineUnit({ + slug: "bd1-09-consultas-reportes", + title: "Consultas SQL y reportes", + description: + "Combina SELECT, JOIN, agregados, GROUP BY y HAVING para construir reportes que responden una pregunta de negocio.", + icon: "📊", + published: true, + lessons: [leccion01, leccion02, leccion03, leccion04, leccion05, leccion06], + practice, +}); diff --git a/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/01-select-where-order.ts b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/01-select-where-order.ts new file mode 100644 index 0000000..f8a0cce --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/01-select-where-order.ts @@ -0,0 +1,41 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion01 = defineLesson({ + slug: "select-where-order", + title: "SELECT, WHERE y ORDER BY", + description: "Recupera columnas/filas concretas en un orden determinista.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Una consulta útil responde una pregunta. \`SELECT\` elige atributos, \`FROM\` la fuente, \`WHERE\` filtra y \`ORDER BY\` hace determinista el orden cuando importa. + +No uses \`SELECT *\` por defecto en reportes: pide las columnas que la decisión necesita.`, + }, + { + type: "code_example", + code: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300); +SELECT id,estado,costo FROM ticket WHERE estado='ABIERTO' ORDER BY costo DESC,id;`, + explanation: "La consulta filtra y ordena.", + runnable: true, + expectedOutput: `10|ABIERTO|200 +12|ABIERTO|100`, + }, + { + type: "quiz", + question: "¿Qué cláusula limita filas?", + options: ["ORDER BY", "WHERE", "SELECT", "FROM"], + correctIndex: 1, + explanation: "WHERE expresa predicado.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "SELECT id FROM ticket {{0}} estado='ABIERTO' {{1}} BY id;", + blanks: [{ answer: "WHERE" }, { answer: "ORDER" }], + explanation: "Filtra y luego ordena.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/02-join.ts b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/02-join.ts new file mode 100644 index 0000000..72f8e35 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/02-join.ts @@ -0,0 +1,50 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion02 = defineLesson({ + slug: "join", + title: "JOIN entre tablas", + description: "Reconstruye información distribuida en relaciones normalizadas.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Normalizar separa hechos; \`JOIN\` los vuelve a combinar para una consulta. + +La condición \`ON\` debe representar la relación correcta. Un join incorrecto puede producir filas plausibles pero falsas.`, + }, + { + type: "code_example", + code: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL'); +CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300); +SELECT ticket.id,cliente.nombre FROM ticket JOIN cliente ON cliente.id=ticket.cliente_id ORDER BY ticket.id;`, + explanation: "La FK guía el join.", + runnable: true, + expectedOutput: `10|Ana +11|Ana +12|Luis +13|Mara`, + }, + { + type: "quiz", + question: "¿Qué condición conecta Ticket con Cliente?", + options: [ + "ticket.id=cliente.id", + "ticket.cliente_id=cliente.id", + "ticket.estado=cliente.nombre", + "COUNT(*)", + ], + correctIndex: 1, + explanation: "Une FK con PK.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "JOIN cliente ON cliente.id={{0}}", + blanks: [{ answer: "ticket.cliente_id" }], + explanation: "La igualdad representa la relación.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/03-agregados.ts b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/03-agregados.ts new file mode 100644 index 0000000..baf3989 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/03-agregados.ts @@ -0,0 +1,40 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion03 = defineLesson({ + slug: "agregados", + title: "COUNT, SUM, AVG, MIN y MAX", + description: "Resume conjuntos de filas.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Los agregados convierten muchas filas en medidas: contar tickets, sumar costos, calcular promedio, mínimos o máximos. + +La pregunta debe definir claramente qué conjunto entra al cálculo.`, + }, + { + type: "code_example", + code: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300); +SELECT COUNT(*),SUM(costo),MIN(costo),MAX(costo) FROM ticket;`, + explanation: "Cuatro métricas del mismo conjunto.", + runnable: true, + expectedOutput: `4|1100|100|500`, + }, + { + type: "quiz", + question: "¿Qué función cuenta filas?", + options: ["SUM", "COUNT", "AVG", "MAX"], + correctIndex: 1, + explanation: "COUNT(*) cuenta filas.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "SELECT {{0}}(costo) FROM ticket;", + blanks: [{ answer: "SUM" }], + explanation: "SUM agrega valores numéricos.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/04-group-by.ts b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/04-group-by.ts new file mode 100644 index 0000000..8cd72ab --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/04-group-by.ts @@ -0,0 +1,41 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion04 = defineLesson({ + slug: "group-by", + title: "GROUP BY y reportes por categoría", + description: "Obtén métricas por cliente, estado u otra dimensión.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `\`GROUP BY\` particiona las filas por una dimensión y calcula agregados dentro de cada grupo. + +Ejemplo: cantidad de tickets por estado. Es la base de muchos reportes operativos.`, + }, + { + type: "code_example", + code: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300); +SELECT estado,COUNT(*) FROM ticket GROUP BY estado ORDER BY estado;`, + explanation: "Cada estado produce su conteo.", + runnable: true, + expectedOutput: `ABIERTO|2 +CERRADO|2`, + }, + { + type: "quiz", + question: "¿Qué cláusula crea grupos para agregados?", + options: ["WHERE", "GROUP BY", "ORDER BY", "DELETE"], + correctIndex: 1, + explanation: "GROUP BY define grupos.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "SELECT estado,COUNT(*) FROM ticket {{0}} estado;", + blanks: [{ answer: "GROUP BY" }], + explanation: "Agrupa por la dimensión reportada.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/05-having.ts b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/05-having.ts new file mode 100644 index 0000000..e0db074 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/05-having.ts @@ -0,0 +1,40 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion05 = defineLesson({ + slug: "having", + title: "HAVING: filtrar grupos", + description: "Distingue filtro de filas de filtro de resultados agregados.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `\`WHERE\` filtra filas antes de agrupar. \`HAVING\` filtra grupos después de calcular agregados. + +"Clientes con al menos 2 tickets" necesita agrupar por cliente y luego aplicar \`HAVING COUNT(*)>=2\`.`, + }, + { + type: "code_example", + code: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300); +SELECT cliente_id,COUNT(*) FROM ticket GROUP BY cliente_id HAVING COUNT(*)>=2 ORDER BY cliente_id;`, + explanation: "Sólo cliente 1 tiene dos tickets.", + runnable: true, + expectedOutput: `1|2`, + }, + { + type: "quiz", + question: "¿Qué cláusula filtra por `COUNT(*)>=2`?", + options: ["WHERE", "HAVING", "FROM", "INSERT"], + correctIndex: 1, + explanation: "El predicado usa un agregado, por eso filtra grupos.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "GROUP BY cliente_id {{0}} COUNT(*)>=2", + blanks: [{ answer: "HAVING" }], + explanation: "HAVING actúa tras agrupar.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/06-reporte-negocio.ts b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/06-reporte-negocio.ts new file mode 100644 index 0000000..f068e30 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/lessons/06-reporte-negocio.ts @@ -0,0 +1,52 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion06 = defineLesson({ + slug: "reporte-negocio", + title: "Construir un reporte útil", + description: "Combina join, filtro, agrupación y orden para responder una decisión.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Reporte: "por cliente, cuántos tickets cerrados y cuánto costo acumulan". + +Necesita Cliente↔Ticket, seleccionar cerrados, agrupar por cliente y sumar. El valor del query no es su complejidad sino que responde una pregunta definida.`, + }, + { + type: "code_completion", + prompt: "Ordena correctamente.", + lines: [ + "Unir Cliente↔Ticket", + "Filtrar CERRADO", + "Agrupar por cliente", + "Calcular COUNT/SUM", + "Ordenar reporte", + ], + explanation: "La consulta compleja se diseña por etapas.", + }, + { + type: "quiz", + question: "¿Qué información NO hace falta para ese reporte?", + options: ["Cliente", "Estado", "Costo", "Color favorito del técnico"], + correctIndex: 3, + explanation: "Sólo conserva datos justificados por la pregunta.", + }, + { + type: "code_example", + code: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL'); +CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300); +SELECT cliente.nombre,COUNT(*),SUM(ticket.costo) +FROM cliente JOIN ticket ON ticket.cliente_id=cliente.id +WHERE ticket.estado='CERRADO' +GROUP BY cliente.id,cliente.nombre +ORDER BY cliente.nombre;`, + explanation: "El resultado puede alimentar una decisión.", + runnable: true, + expectedOutput: `Ana|1|500 +Mara|1|300`, + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/practice.ts b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/practice.ts new file mode 100644 index 0000000..d5e0282 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/09-consultas-reportes/practice.ts @@ -0,0 +1,175 @@ +import type { PracticeExerciseDefinition } from "../../../../exercises/types"; + +export const practice = [ + { + slug: "bd1-q-abiertos", + title: "Abiertos por costo", + description: "Devuelve id|costo de tickets abiertos, costo descendente.", + prompt: "Devuelve id|costo de tickets abiertos, costo descendente.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT id,costo FROM ticket WHERE estado='ABIERTO' ORDER BY costo DESC,id;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `10|200 +12|100`, + visible: true, + }, + { + description: "dataset oculto", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT,ciudad TEXT); CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO cliente VALUES(5,'Eva','X'),(8,'Noe','Y'); INSERT INTO ticket VALUES(1,5,'CERRADO',50),(2,5,'ABIERTO',20),(3,5,'CERRADO',70),(4,8,'ABIERTO',100);`, + expectedStdout: `4|100 +2|20`, + visible: false, + }, + ], + }, + { + slug: "bd1-q-join-cliente", + title: "Tickets con cliente", + description: "Devuelve id|nombre de cada ticket.", + prompt: "Devuelve id|nombre de cada ticket.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT ticket.id,cliente.nombre FROM ticket JOIN cliente ON cliente.id=ticket.cliente_id ORDER BY ticket.id;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL'); +CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `10|Ana +11|Ana +12|Luis +13|Mara`, + visible: true, + }, + { + description: "dataset oculto", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT,ciudad TEXT); CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO cliente VALUES(5,'Eva','X'),(8,'Noe','Y'); INSERT INTO ticket VALUES(1,5,'CERRADO',50),(2,5,'ABIERTO',20),(3,5,'CERRADO',70),(4,8,'ABIERTO',100);`, + expectedStdout: `1|Eva +2|Eva +3|Eva +4|Noe`, + visible: false, + }, + ], + }, + { + slug: "bd1-q-total", + title: "Costo total", + description: "Devuelve suma de costo.", + prompt: "Devuelve suma de costo.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT SUM(costo) FROM ticket;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `1100`, + visible: true, + }, + { + description: "dataset oculto", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT,ciudad TEXT); CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO cliente VALUES(5,'Eva','X'),(8,'Noe','Y'); INSERT INTO ticket VALUES(1,5,'CERRADO',50),(2,5,'ABIERTO',20),(3,5,'CERRADO',70),(4,8,'ABIERTO',100);`, + expectedStdout: `240`, + visible: false, + }, + ], + }, + { + slug: "bd1-q-por-estado", + title: "Conteo por estado", + description: "Devuelve estado|cantidad ordenado.", + prompt: "Devuelve estado|cantidad ordenado.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT estado,COUNT(*) FROM ticket GROUP BY estado ORDER BY estado;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `ABIERTO|2 +CERRADO|2`, + visible: true, + }, + { + description: "dataset oculto", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT,ciudad TEXT); CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO cliente VALUES(5,'Eva','X'),(8,'Noe','Y'); INSERT INTO ticket VALUES(1,5,'CERRADO',50),(2,5,'ABIERTO',20),(3,5,'CERRADO',70),(4,8,'ABIERTO',100);`, + expectedStdout: `ABIERTO|2 +CERRADO|2`, + visible: false, + }, + ], + }, + { + slug: "bd1-q-clientes-multiples", + title: "Clientes con >=2 tickets", + description: "Devuelve cliente_id|cantidad.", + prompt: "Devuelve cliente_id|cantidad.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT cliente_id,COUNT(*) FROM ticket GROUP BY cliente_id HAVING COUNT(*)>=2 ORDER BY cliente_id;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `1|2`, + visible: true, + }, + { + description: "dataset oculto", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT,ciudad TEXT); CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO cliente VALUES(5,'Eva','X'),(8,'Noe','Y'); INSERT INTO ticket VALUES(1,5,'CERRADO',50),(2,5,'ABIERTO',20),(3,5,'CERRADO',70),(4,8,'ABIERTO',100);`, + expectedStdout: `5|3`, + visible: false, + }, + ], + }, + { + slug: "bd1-q-reporte", + title: "Reporte cerrados", + description: "Devuelve cliente|cantidad|total de tickets cerrados.", + prompt: "Devuelve cliente|cantidad|total de tickets cerrados.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT cliente.nombre,COUNT(*),SUM(ticket.costo) FROM cliente JOIN ticket ON ticket.cliente_id=cliente.id WHERE ticket.estado='CERRADO' GROUP BY cliente.id,cliente.nombre ORDER BY cliente.nombre;`, + difficulty: "medium", + xpReward: 25, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL'); +CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `Ana|1|500 +Mara|1|300`, + visible: true, + }, + { + description: "dataset oculto", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT,ciudad TEXT); CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO cliente VALUES(5,'Eva','X'),(8,'Noe','Y'); INSERT INTO ticket VALUES(1,5,'CERRADO',50),(2,5,'ABIERTO',20),(3,5,'CERRADO',70),(4,8,'ABIERTO',100);`, + expectedStdout: `Eva|2|120`, + visible: false, + }, + ], + }, +] satisfies PracticeExerciseDefinition[]; diff --git a/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/index.ts b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/index.ts new file mode 100644 index 0000000..359ef51 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/index.ts @@ -0,0 +1,20 @@ +import { defineUnit } from "../../../../authoring"; + +import { leccion01 } from "./lessons/01-por-que-respaldar"; +import { leccion02 } from "./lessons/02-estrategia-respaldo"; +import { leccion03 } from "./lessons/03-integrador-modelo"; +import { leccion04 } from "./lessons/04-integrador-sql"; +import { leccion05 } from "./lessons/05-integrador-entrega"; + +import { practice } from "./practice"; + +export const bd1RespaldoIntegrador = defineUnit({ + slug: "bd1-10-respaldo-integrador", + title: "Respaldo, restauración y proyecto integrador", + description: + "Diseña una estrategia de respaldo/restauración y cierra el proyecto integrador de la materia.", + icon: "🧰", + published: true, + lessons: [leccion01, leccion02, leccion03, leccion04, leccion05], + practice, +}); diff --git a/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/01-por-que-respaldar.ts b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/01-por-que-respaldar.ts new file mode 100644 index 0000000..07ed7af --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/01-por-que-respaldar.ts @@ -0,0 +1,46 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion01 = defineLesson({ + slug: "por-que-respaldar", + title: "Qué protege un respaldo", + description: "Distingue respaldo de disponibilidad y define qué debe poder recuperarse.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Un respaldo es una copia recuperable de datos/esquema para responder a pérdida o corrupción. Tener un archivo llamado \`backup\` no demuestra nada: debe existir una restauración verificable. + +Frecuencia, retención y ubicación dependen del impacto de perder datos.`, + }, + { + type: "matching", + pairs: [ + { left: "Backup", right: "Copia recuperable" }, + { left: "Restore", right: "Reconstruir estado desde respaldo" }, + { left: "Prueba de restore", right: "Evidencia de que el respaldo sirve" }, + { left: "Retención", right: "Cuánto historial se conserva" }, + ], + explanation: "Respaldar sin restaurar de prueba deja una suposición.", + }, + { + type: "quiz", + question: "¿Cuál es la mejor evidencia de un backup útil?", + options: [ + "El archivo existe", + "Se restauró en entorno limpio y las verificaciones pasaron", + "Pesa mucho", + "Tiene fecha", + ], + correctIndex: 1, + explanation: "La recuperación probada demuestra utilidad.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "backup + prueba de {{0}} = evidencia de recuperación", + blanks: [{ answer: "restore" }], + explanation: "La restauración cierra el ciclo.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/02-estrategia-respaldo.ts b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/02-estrategia-respaldo.ts new file mode 100644 index 0000000..d6bfc5b --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/02-estrategia-respaldo.ts @@ -0,0 +1,52 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion02 = defineLesson({ + slug: "estrategia-respaldo", + title: "Alcance, frecuencia y restauración", + description: "Diseña una estrategia simple basada en pérdida tolerable.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Pregunta primero cuánto dato puedes permitirte perder y cuánto tiempo puede tardar recuperar servicio. En BD I no profundizamos en RPO/RTO formal, pero sí en la lógica: más cambios y mayor impacto suelen exigir respaldos más frecuentes y restauración ensayada. + +El comando exacto depende del SGBD y se practica localmente.`, + }, + { + type: "matching", + pairs: [ + { left: "Datos cambian cada minuto", right: "Necesita estrategia más frecuente" }, + { left: "Catálogo casi estático", right: "Puede tolerar frecuencia menor" }, + { left: "Backup en mismo disco único", right: "Riesgo común" }, + { left: "Restore nunca probado", right: "Confianza no demostrada" }, + ], + explanation: "La estrategia responde al riesgo.", + }, + { + type: "quiz", + question: + "¿Por qué el comando de backup no se evalúa en SQLite/Wandbox como si fuera universal?", + options: [ + "Porque backup no existe", + "Porque herramientas/formato dependen del SGBD y entorno", + "Porque SQL no sirve", + "Porque CETI no lo pide", + ], + correctIndex: 1, + explanation: "El concepto es general; la herramienta es específica.", + }, + { + type: "code_completion", + prompt: "Ordena correctamente.", + lines: [ + "Definir qué proteger", + "Elegir frecuencia/retención", + "Crear respaldo", + "Restaurar en entorno limpio", + "Verificar datos/esquema", + ], + explanation: "La restauración es parte de la estrategia.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/03-integrador-modelo.ts b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/03-integrador-modelo.ts new file mode 100644 index 0000000..42b4656 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/03-integrador-modelo.ts @@ -0,0 +1,54 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion03 = defineLesson({ + slug: "integrador-modelo", + title: "Integrador I: necesidades, ER y normalización", + description: "Cierra el diseño antes de implementar SQL.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Proyecto: base de datos para una pequeña empresa de soporte técnico. + +Entregables de diseño: +1. problema y actores; +2. necesidades de información; +3. entidades/atributos; +4. cardinalidades; +5. modelo relacional; +6. justificación de normalización hasta 3FN para los casos del proyecto. + +No se acepta un diagrama desconectado de las consultas que debe soportar.`, + }, + { + type: "matching", + pairs: [ + { left: "Necesidad", right: "Tickets abiertos por técnico" }, + { left: "ER", right: "Técnico↔Ticket" }, + { left: "Relacional", right: "asignacion con FKs" }, + { left: "Normalización", right: "Hechos de técnico fuera de asignacion" }, + ], + explanation: "El diseño mantiene trazabilidad.", + }, + { + type: "quiz", + question: "¿Qué debe justificar una tabla nueva?", + options: [ + "Que queda bonita", + "Un hecho/relación necesario y su dependencia", + "Que tiene muchas columnas", + "Que el SGBD la permite", + ], + correctIndex: 1, + explanation: "Cada relación debe representar hechos del dominio.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "necesidad → ER → modelo relacional → {{0}}", + blanks: [{ answer: "normalización" }], + explanation: "Después llega DDL.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/04-integrador-sql.ts b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/04-integrador-sql.ts new file mode 100644 index 0000000..fd9a6e2 --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/04-integrador-sql.ts @@ -0,0 +1,56 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion04 = defineLesson({ + slug: "integrador-sql", + title: "Integrador II: DDL, DML y consultas", + description: "Construye un script reproducible que cree, cargue y consulte la base.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `El script del proyecto debe poder ejecutarse desde una base vacía y producir: +- tablas/restricciones; +- datos de demostración; +- operaciones DML; +- consultas/reportes definidos desde los requerimientos. + +La reproducibilidad permite evaluar el sistema sin depender del estado manual de una computadora.`, + }, + { + type: "code_completion", + prompt: "Ordena correctamente.", + lines: [ + "Crear esquema DDL", + "Insertar datos de demostración", + "Ejecutar DML relevante", + "Ejecutar reportes", + "Guardar evidencia", + ], + explanation: "Un script reproducible es parte del entregable.", + }, + { + type: "quiz", + question: "¿Qué es mejor para demostrar la BD?", + options: [ + "Sólo capturas", + "Script reproducible + resultados de consultas", + "Una tabla manual", + "Una descripción sin SQL", + ], + correctIndex: 1, + explanation: "El evaluador puede reconstruir y verificar.", + }, + { + type: "code_example", + code: `CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT NOT NULL); +CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER NOT NULL,estado TEXT NOT NULL); +INSERT INTO cliente VALUES(1,'Ana'); +INSERT INTO ticket VALUES(10,1,'ABIERTO'),(11,1,'CERRADO'); +SELECT cliente.nombre,COUNT(*) FROM cliente JOIN ticket ON ticket.cliente_id=cliente.id GROUP BY cliente.id,cliente.nombre;`, + explanation: "Es una miniatura del pipeline DDL→datos→reporte.", + runnable: true, + expectedOutput: `Ana|2`, + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/05-integrador-entrega.ts b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/05-integrador-entrega.ts new file mode 100644 index 0000000..9e3f07c --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/lessons/05-integrador-entrega.ts @@ -0,0 +1,54 @@ +import { defineLesson } from "../../../../../authoring"; + +export const leccion05 = defineLesson({ + slug: "integrador-entrega", + title: "Integrador III: aplicación, respaldo y reporte", + description: "Define la entrega completa sin fingir que el navegador reemplaza el laboratorio local.", + estimatedMinutes: 15, + xpReward: 45, + steps: [ + { + type: "theory", + markdown: `Entrega final: + +- script SQL completo; +- modelo ER y relacional normalizado; +- evidencias de queries; +- respaldo y restauración demostrada localmente; +- reporte con problema, necesidades, modelado, normalización y reflexión; +- integración local con una aplicación de escritorio que gestione la información, como pide el programa oficial. + +cpp-ceti evalúa el núcleo relacional y SQL. La GUI y operación administrativa se validan como laboratorio local.`, + }, + { + type: "matching", + pairs: [ + { left: "Script SQL", right: "Reproducibilidad" }, + { left: "ER/relacional", right: "Diseño" }, + { left: "Queries", right: "Evidencia de recuperación/análisis" }, + { left: "Backup+restore", right: "Recuperabilidad" }, + { left: "GUI local", right: "Integración con aplicación" }, + ], + explanation: "El producto integrador une artefactos distintos.", + }, + { + type: "quiz", + question: "¿Debe el Course ejecutar Windows Forms dentro de Wandbox SQL?", + options: [ + "Sí", + "No; la GUI es laboratorio local y el SQL se verifica en su runtime", + "Sólo si usa SELECT", + "Siempre", + ], + correctIndex: 1, + explanation: "Cada runtime debe evaluar lo que realmente puede ejecutar.", + }, + { + type: "fill_blank", + prompt: "Completa los espacios.", + template: "Diseño + SQL + evidencias + backup/restore + {{0}} local", + blanks: [{ answer: "aplicación" }], + explanation: "Es el producto completo.", + }, + ], +}); diff --git a/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/practice.ts b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/practice.ts new file mode 100644 index 0000000..400acfb --- /dev/null +++ b/prisma/content/courses/bases-de-datos/units/10-respaldo-integrador/practice.ts @@ -0,0 +1,90 @@ +import type { PracticeExerciseDefinition } from "../../../../exercises/types"; + +export const practice = [ + { + slug: "bd1-final-esquema", + title: "Esquema mínimo integrador", + description: "Crea cliente y ticket con FK; inserta un cliente y un ticket; devuelve ticket|cliente mediante JOIN.", + prompt: + "Crea cliente y ticket con FK; inserta un cliente y un ticket; devuelve ticket|cliente mediante JOIN.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `PRAGMA foreign_keys=ON; +CREATE TABLE cliente(id INTEGER PRIMARY KEY,nombre TEXT NOT NULL); +CREATE TABLE ticket(id INTEGER PRIMARY KEY,cliente_id INTEGER NOT NULL,estado TEXT NOT NULL,FOREIGN KEY(cliente_id) REFERENCES cliente(id)); +INSERT INTO cliente VALUES(1,'Ana'); INSERT INTO ticket VALUES(10,1,'ABIERTO'); +SELECT ticket.id,cliente.nombre FROM ticket JOIN cliente ON cliente.id=ticket.cliente_id;`, + difficulty: "hard", + xpReward: 40, + testCases: [ + { + description: "script completo", + stdin: ``, + expectedStdout: `10|Ana`, + visible: true, + }, + { + description: "ejecución limpia", + stdin: ``, + expectedStdout: `10|Ana`, + visible: false, + }, + ], + }, + { + slug: "bd1-final-reporte", + title: "Reporte operativo", + description: "Con fixture completo, devuelve cliente|tickets|costo total.", + prompt: "Con fixture completo, devuelve cliente|tickets|costo total.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT cliente.nombre,COUNT(*),SUM(ticket.costo) FROM cliente JOIN ticket ON ticket.cliente_id=cliente.id GROUP BY cliente.id,cliente.nombre ORDER BY cliente.nombre;`, + difficulty: "hard", + xpReward: 35, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE cliente(id INTEGER PRIMARY KEY, nombre TEXT NOT NULL, ciudad TEXT NOT NULL); +INSERT INTO cliente VALUES (1,'Ana','GDL'),(2,'Luis','Zapopan'),(3,'Mara','GDL'); +CREATE TABLE ticket(id INTEGER PRIMARY KEY, cliente_id INTEGER NOT NULL, estado TEXT NOT NULL, costo INTEGER NOT NULL); +INSERT INTO ticket VALUES (10,1,'ABIERTO',200),(11,1,'CERRADO',500),(12,2,'ABIERTO',100),(13,3,'CERRADO',300);`, + expectedStdout: `Ana|2|700 +Luis|1|100 +Mara|1|300`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE cliente(id INTEGER,nombre TEXT,ciudad TEXT); CREATE TABLE ticket(id INTEGER,cliente_id INTEGER,estado TEXT,costo INTEGER); INSERT INTO cliente VALUES(1,'Eva','X'); INSERT INTO ticket VALUES(1,1,'A',20),(2,1,'B',30);`, + expectedStdout: `Eva|2|50`, + visible: false, + }, + ], + }, + { + slug: "bd1-final-normalizado", + title: "Consulta normalizada", + description: "Con cliente, técnico, ticket y asignación, devuelve ticket|cliente|técnico|horas.", + prompt: "Con cliente, técnico, ticket y asignación, devuelve ticket|cliente|técnico|horas.", + starterCode: `-- escribe tu SQL aquí +`, + solutionCode: `SELECT ticket.id,cliente.nombre,tecnico.nombre,asignacion.horas FROM ticket JOIN cliente ON cliente.id=ticket.cliente_id JOIN asignacion ON asignacion.ticket_id=ticket.id JOIN tecnico ON tecnico.id=asignacion.tecnico_id ORDER BY ticket.id,tecnico.id;`, + difficulty: "hard", + xpReward: 40, + testCases: [ + { + description: "visible", + stdin: `CREATE TABLE cliente(id INTEGER,nombre TEXT); CREATE TABLE tecnico(id INTEGER,nombre TEXT); CREATE TABLE ticket(id INTEGER,cliente_id INTEGER); CREATE TABLE asignacion(ticket_id INTEGER,tecnico_id INTEGER,horas INTEGER); INSERT INTO cliente VALUES(1,'Ana'); INSERT INTO tecnico VALUES(2,'Leo'),(3,'Sol'); INSERT INTO ticket VALUES(10,1); INSERT INTO asignacion VALUES(10,2,2),(10,3,1);`, + expectedStdout: `10|Ana|Leo|2 +10|Ana|Sol|1`, + visible: true, + }, + { + description: "oculto", + stdin: `CREATE TABLE cliente(id INTEGER,nombre TEXT); CREATE TABLE tecnico(id INTEGER,nombre TEXT); CREATE TABLE ticket(id INTEGER,cliente_id INTEGER); CREATE TABLE asignacion(ticket_id INTEGER,tecnico_id INTEGER,horas INTEGER); INSERT INTO cliente VALUES(8,'Eva'); INSERT INTO tecnico VALUES(5,'Noe'); INSERT INTO ticket VALUES(4,8); INSERT INTO asignacion VALUES(4,5,6);`, + expectedStdout: `4|Eva|Noe|6`, + visible: false, + }, + ], + }, +] satisfies PracticeExerciseDefinition[]; diff --git a/prisma/content/courses/index.ts b/prisma/content/courses/index.ts index f2ad7a7..f26e8a2 100644 --- a/prisma/content/courses/index.ts +++ b/prisma/content/courses/index.ts @@ -14,6 +14,7 @@ import { buildContentRegistry } from "../authoring"; import type { CoursePackageDefinition } from "../authoring"; +import { basesDeDatos } from "./bases-de-datos"; import { cppDesdeCero } from "./cpp-desde-cero"; import { csharpPoo1 } from "./csharp-poo-1"; import { modelosMetodosDesarrolloSoftware } from "./modelos-metodos-desarrollo-software"; @@ -22,6 +23,7 @@ const packages = [ cppDesdeCero, csharpPoo1, modelosMetodosDesarrolloSoftware, + basesDeDatos, ] satisfies readonly CoursePackageDefinition[]; export const { allCourses, allPracticeSets } = buildContentRegistry(packages); diff --git a/prisma/migrations/20260902130000_add_sql_language/migration.sql b/prisma/migrations/20260902130000_add_sql_language/migration.sql new file mode 100644 index 0000000..065e80c --- /dev/null +++ b/prisma/migrations/20260902130000_add_sql_language/migration.sql @@ -0,0 +1,6 @@ +-- Base de Datos I: agrega `sql` al enum de lenguajes soportados. +-- ALTER TYPE ... ADD VALUE es seguro (idempotente con IF NOT EXISTS) y el +-- valor nuevo no se usa en esta misma migración — el seed crea el Course +-- `bases-de-datos` en una corrida posterior. + +ALTER TYPE "ProgrammingLanguage" ADD VALUE IF NOT EXISTS 'sql'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 67eefcc..6a25abc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -164,6 +164,7 @@ model Verification { enum ProgrammingLanguage { cpp csharp + sql } model Course { diff --git a/scripts/node-sqlite.d.ts b/scripts/node-sqlite.d.ts new file mode 100644 index 0000000..7165256 --- /dev/null +++ b/scripts/node-sqlite.d.ts @@ -0,0 +1,24 @@ +// `node:sqlite` es experimental desde Node 22.5 (sin dependencias +// externas). `@types/node` en este repo está fijado en la línea 20 y no +// trae sus tipos todavía. Declaración ambiental mínima con SÓLO lo que +// `scripts/verify-content.ts` usa — la incompatibilidad real es de versión +// de TIPOS, no de runtime: `node --version` en este entorno ya es 22.x y el +// módulo existe. +declare module "node:sqlite" { + export class StatementSync { + /** + * Filas como ARRAY posicional en vez de objeto por nombre de columna. + * Necesario: dos columnas con el mismo nombre (`a.nombre, b.nombre`) + * colisionarían en un solo objeto JS y perderían una — sqlite3 CLI no + * tiene ese problema porque imprime por posición, no por nombre. + */ + setReturnArrays(value: boolean): void; + all(): unknown[][]; + } + export class DatabaseSync { + constructor(location: string); + exec(sql: string): void; + prepare(sql: string): StatementSync; + close(): void; + } +} diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts index 779f488..96a7334 100644 --- a/scripts/verify-content.ts +++ b/scripts/verify-content.ts @@ -17,6 +17,13 @@ * Toolchains (locales, NO el proveedor de producción): * cpp → g++ -std=c++17 -O0 -Wall * csharp → mcs + mono (mismo compilador que el perfil csharp-mono-6.12) + * sql → node:sqlite (DatabaseSync), una base efímera en memoria POR CASO + * + * SQL no usa el `sqlite3` CLI: no está instalado en este entorno de + * verificación (ni necesariamente en CI). `node:sqlite` (disponible desde + * Node 22, sin dependencias externas) es el mecanismo "ya disponible más + * barato/estable" que pide TECHNICAL_CONTRACT §5 — sin introducir una base + * compartida: cada caso abre y descarta su propia `:memory:`. * * Uso: * npx tsx scripts/verify-content.ts # todos los cursos @@ -26,6 +33,7 @@ import { execFile } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { promisify } from "node:util"; import { allCourses } from "../prisma/content"; @@ -33,6 +41,10 @@ import { allPracticeSets } from "../prisma/content/exercises"; import type { LanguageId } from "../src/lib/code-languages"; import { normalizeOutput } from "../src/lib/executor/normalize"; +// Los tipos de `node:sqlite` viven en `scripts/node-sqlite.d.ts` — ver ese +// archivo para el porqué (incompatibilidad de versión de `@types/node`, +// no de runtime). + const run = promisify(execFile); const RUN_TIMEOUT_MS = 15_000; @@ -92,6 +104,10 @@ interface Failure { async function compileAndRun(unit: Unit, dir: string): Promise { const failures: Failure[] = []; + if (unit.language === "sql") { + return runSqlCases(unit); + } + if (unit.language === "cpp") { const src = join(dir, "main.cpp"); const bin = join(dir, "program"); @@ -127,13 +143,102 @@ async function compileAndRun(unit: Unit, dir: string): Promise { * ¿El programa lee de la entrada estándar? Un ejemplo interactivo no se * puede verificar por salida sin darle una entrada que el contenido no * declara. + * + * SQL no tiene equivalente: un script no lee entrada interactiva mientras + * corre (su "stdin" es el fixture, ver TECHNICAL_CONTRACT §4) — siempre + * `false`. */ function readsStdin(code: string, language: LanguageId): boolean { + if (language === "sql") return false; return language === "cpp" ? /\bcin\s*>>|\bscanf\s*\(|\bgetline\s*\(|\bfgets\s*\(/.test(code) : /\bConsole\s*\.\s*Read(Line|Key)?\s*\(/.test(code); } +/** + * Ejecuta los casos de una pieza SQL. A diferencia de C++/C#, aquí NO hay + * un binario compartido: cada caso construye su propio `effectiveSource` + * (fixture + solución, exactamente como `WandboxExecutor.runTests` — ver + * TECHNICAL_CONTRACT §4) y corre contra una `:memory:` NUEVA. Dos casos del + * mismo ejercicio nunca comparten estado. + */ +function runSqlCases(unit: Unit): Failure[] { + const failures: Failure[] = []; + for (const testCase of unit.cases) { + const label = testCase.description + ? `${unit.id} [${testCase.description}]` + : `${unit.id} [fixture=${JSON.stringify(testCase.stdin)}]`; + const effectiveSource = `${testCase.stdin}\n${unit.code}`; + + const db = new DatabaseSync(":memory:"); + try { + const actual = runSqlScript(db, effectiveSource); + if (!testCase.compareOutput) continue; + const expected = normalizeOutput(testCase.expectedStdout); + const normalizedActual = normalizeOutput(actual); + if (normalizedActual !== expected) { + failures.push({ + id: label, + kind: "output", + detail: `esperado:\n${expected}\nobtenido:\n${normalizedActual}`, + }); + } + } catch (err) { + failures.push({ + id: label, + kind: "runtime", + detail: err instanceof Error ? err.message : String(err), + }); + } finally { + db.close(); + } + } + return failures; +} + +/** + * Corre un script con múltiples sentencias contra una DB abierta y devuelve + * su salida en el formato "list mode" del CLI de SQLite (el que asumen los + * `expectedStdout` del paquete): sin encabezados, columnas separadas por + * `|`, NULL como cadena vacía, una fila por línea. Sólo las sentencias + * `SELECT` producen salida — el resto (DDL/DML/PRAGMA de configuración) se + * ejecuta por su efecto. + */ +function runSqlScript(db: DatabaseSync, script: string): string { + const lines: string[] = []; + for (const statement of splitSqlStatements(script)) { + if (/^select\b/i.test(statement)) { + const stmt = db.prepare(statement); + stmt.setReturnArrays(true); + for (const row of stmt.all()) { + lines.push(row.map(formatSqlValue).join("|")); + } + } else { + db.exec(statement); + } + } + return lines.join("\n"); +} + +/** `NULL` imprime como cadena vacía en list mode; todo lo demás, su texto. */ +function formatSqlValue(value: unknown): string { + if (value === null || value === undefined) return ""; + return String(value); +} + +/** + * Separa un script en sentencias individuales por `;`. Naïve a propósito + * (sin parser SQL, ver TECHNICAL_CONTRACT §7): el contenido del curso no + * usa `;` dentro de literales ni comentarios con `;`, así que partir por el + * delimitador es suficiente y exactamente lo que hace `sqlite3 < script.sql`. + */ +function splitSqlStatements(script: string): string[] { + return script + .split(";") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + async function runCase( unit: Unit, command: string, diff --git a/src/components/editor/code-playground.tsx b/src/components/editor/code-playground.tsx index 93f547e..44e8c3e 100644 --- a/src/components/editor/code-playground.tsx +++ b/src/components/editor/code-playground.tsx @@ -70,7 +70,7 @@ export function CodePlayground({ {showStdin ? (