Camilo Merino
Base abierta de ingeniería
Bases de Datos

Unidad 12 · Transacciones, índices y la base dentro de un programa

Descargar PDFVerificadores

Qué vas a poder hacer. Agrupar las sentencias de una operación del negocio en una transacción que se confirma entera o se deshace entera, reconocer qué pasa cuando falla a la mitad y cuando dos personas escriben a la vez, decidir qué índice crear leyendo el plan de una consulta, y usar la base desde Python con consultas parametrizadas, sin abrirle la puerta a la inyección SQL.

Lo que necesitas antes. La unidad 5 (claves primarias y foráneas, CHECK, NOT NULL), la 8 (INSERT, UPDATE, DELETE), la 10 (JOIN) y la 11 (la vista v_ventas_tienda). De Python basta saber escribir una función, un for y un try/except.

Tiempo. Unas 7 horas: 3 de lectura con SQLite y Python abiertos y 4 de práctica.

Cómo correr todo. Los scripts están en sql/. u12_transacciones.sql crea la base del ejemplo y es una sesión: hay que correrla entera y en orden, porque cada paso depende del anterior y algunas sentencias fallan a propósito. u12_caso.sql es la sesión sobre una copia de datos/estampa_sur.db (registra pedidos y crea índices). u12_programa.py tiene todas las funciones de Python de la unidad. Cada resultado es el que devolvieron SQLite 3.50.4 y Python 3.14; el verificador verificacion/u12_transacciones.py los vuelve a obtener sobre copias y los busca en el texto. Este ramo no corrió MySQL: donde MySQL difiere, se dice, pero sus salidas no se muestran.


1. La idea: una operación del negocio son varias sentencias

Vender dos martillos y cinco cintas en una ferretería no es una sentencia. Es registrar la venta, registrar cada línea y descontar el stock de cada producto: cinco sentencias. Si el programa se cae después de la tercera, la base queda con una venta que dice dos martillos y un stock que solo bajó para uno de los productos. Ninguna regla de integridad lo detecta: cada fila, por separado, es válida.

Una transacción es un grupo de sentencias que la base trata como una sola unidad: o quedan todas o no queda ninguna. Las tablas pasan de un estado correcto (antes de la venta) a otro estado correcto (después), sin que nadie vea nunca el estado intermedio.

La base del ejemplo. Una ferretería de Los Ángeles registra ventas y lleva el stock en la misma base. El CHECK (stock >= 0) es la regla que va a hacer fallar una venta a propósito.

PRAGMA foreign_keys = ON;
CREATE TABLE producto (
    id_producto INTEGER PRIMARY KEY,
    nombre      VARCHAR(40) NOT NULL,
    precio      INTEGER NOT NULL CHECK (precio > 0),
    stock       INTEGER NOT NULL CHECK (stock >= 0)
);
CREATE TABLE venta (
    id_venta INTEGER PRIMARY KEY,
    fecha    DATE NOT NULL,
    vendedor VARCHAR(30) NOT NULL
);
CREATE TABLE detalle_venta (
    id_venta    INTEGER NOT NULL REFERENCES venta(id_venta),
    id_producto INTEGER NOT NULL REFERENCES producto(id_producto),
    cantidad    INTEGER NOT NULL CHECK (cantidad > 0),
    precio_unit INTEGER NOT NULL,
    PRIMARY KEY (id_venta, id_producto)
);
SELECT * FROM producto;
id_producto nombre precio stock
1 Tornillo volcanita 6x1 (caja 100) 2990 40
2 Martillo carpintero 16 oz 8490 12
3 Cinta aisladora 1290 60
4 Brocha 2 pulgadas 1890 25
5 Silicona transparente 4590 8
6 Guantes de trabajo 3290 30
SELECT * FROM detalle_venta;
id_venta id_producto cantidad precio_unit
1 1 2 2990
1 3 4 1290
2 2 1 8490
2 6 2 3290
3 5 1 4590
3 4 3 1890
4 1 5 2990
5 3 10 1290
5 6 1 3290

Las ventas 1 a 5 son de Rosa y Tomás entre el 05-10-2026 y el 08-10-2026; el stock de la tabla ya descuenta lo que vendieron.

PRAGMA foreign_keys = ON en SQLite. SQLite no revisa las claves foráneas si no se le pide, y el pedido vale para cada conexión: hay que repetirlo cada vez que se abre la base. MySQL (con InnoDB) las revisa siempre.


2. Las propiedades ACID

Lo que una transacción garantiza se resume en cuatro letras:

Letra Propiedad Qué garantiza En la ferretería
A Atomicidad todo o nada no queda una venta con la mitad de sus líneas
C Consistencia al terminar se cumplen todas las reglas (CHECK, claves, NOT NULL) ninguna transacción confirmada deja un stock negativo
I Aislamiento (isolation) las transacciones simultáneas no se pisan; cada una ve un estado coherente la caja 2 no ve la venta a medio registrar de la caja 1
D Durabilidad lo confirmado sobrevive a un corte de luz tras el COMMIT, la venta está en el disco

La intuición detrás de la A y la D. El motor no escribe directo sobre los datos «buenos»: anota lo que va a cambiar en un diario (journal en SQLite, redo log y undo log en MySQL). Si la transacción se confirma, el diario dice qué quedó; si se deshace o se corta la luz, el diario permite volver atrás. Por eso deshacer es barato y confirmar es seguro.

La C no es solo de la base. La base garantiza las reglas que se le escribieron. «La venta descuenta el stock de cada producto vendido» no está en ningún CHECK: la garantiza el programa que pone las cinco sentencias dentro de la misma transacción.


3. BEGIN, COMMIT y ROLLBACK

Sin transacción explícita, cada sentencia es su propia transacción y se confirma sola al terminar (autocommit). Para agrupar, se abre con BEGIN, se confirma con COMMIT y se deshace con ROLLBACK.

Una venta que sale bien. Tomás vende 2 martillos y 5 cintas:

BEGIN;
INSERT INTO venta VALUES (6, '2026-10-09', 'Tomás');
INSERT INTO detalle_venta VALUES (6, 2, 2, 8490);
UPDATE producto SET stock = stock - 2 WHERE id_producto = 2;
INSERT INTO detalle_venta VALUES (6, 3, 5, 1290);
UPDATE producto SET stock = stock - 5 WHERE id_producto = 3;
COMMIT;
SELECT id_producto, nombre, stock FROM producto WHERE id_producto IN (2, 3);
id_producto nombre stock
2 Martillo carpintero 16 oz 10
3 Cinta aisladora 55

Una venta que falla a la mitad. Rosa vende 3 pares de guantes y 10 siliconas, pero quedan 8 siliconas:

BEGIN;
INSERT INTO venta VALUES (7, '2026-10-09', 'Rosa');
INSERT INTO detalle_venta VALUES (7, 6, 3, 3290);
UPDATE producto SET stock = stock - 3 WHERE id_producto = 6;
INSERT INTO detalle_venta VALUES (7, 5, 10, 4590);
UPDATE producto SET stock = stock - 10 WHERE id_producto = 5;
CHECK constraint failed: stock >= 0

Lo importante: el error no deshizo la transacción. Deshizo solo la sentencia que falló. Las cuatro anteriores siguen ahí, sin confirmar, y la transacción sigue abierta. Mirando desde adentro:

SELECT d.id_venta, d.id_producto, d.cantidad, p.stock
FROM detalle_venta d JOIN producto p ON p.id_producto = d.id_producto
WHERE d.id_venta = 7;
id_venta id_producto cantidad stock
7 5 10 8
7 6 3 27

Media venta: los guantes ya se descontaron (27) y hay una línea que dice 10 siliconas con el stock de silicona intacto (8). Si ahora alguien escribe COMMIT, ese estado queda guardado (ejercicio 12.5). Lo que corresponde es deshacer:

ROLLBACK;
SELECT (SELECT COUNT(*) FROM venta WHERE id_venta = 7) AS ventas_7,
       (SELECT stock FROM producto WHERE id_producto = 6) AS stock_guantes,
       (SELECT stock FROM producto WHERE id_producto = 5) AS stock_silicona;
ventas_7 stock_guantes stock_silicona
0 30 8

La venta 7 no existe y los guantes volvieron a 30. La base quedó exactamente como estaba antes del BEGIN.

La regla. Un error dentro de una transacción no la cierra: quien abrió la transacción decide. Desde la consola lo decides tú; desde un programa, el programa tiene que atrapar el error y hacer ROLLBACK (sección 6). MySQL se comporta igual para los errores de restricción: deshace la sentencia y deja la transacción abierta.

Otros errores que también detienen una sentencia. Una línea con un producto que no existe choca con la clave foránea (con PRAGMA foreign_keys = ON):

INSERT INTO detalle_venta VALUES (6, 9, 1, 1000);
FOREIGN KEY constraint failed

Diferencias con MySQL.


4. Concurrencia y aislamiento, como intuición

Una base la usan varias personas a la vez: dos cajas de la ferretería, el sistema de despacho, el informe que corre el gerente. El aislamiento decide qué ve cada una de lo que hacen las otras mientras sus transacciones están abiertas.

Lo que puede salir mal.

Anomalía Qué pasa Ejemplo
lectura sucia leo un cambio que el otro todavía no confirma (y quizá deshaga) la caja 2 ve 27 guantes de la venta 7, que después se deshizo
lectura no repetible leo la misma fila dos veces en mi transacción y cambió entre medio el informe lee el stock de silicona al principio y al final y no calza
fantasma repito una consulta con condición y aparecen filas nuevas «ventas de hoy» da 6 y, un momento después, 7
actualización perdida dos leen el mismo valor, cada uno calcula y escribe; gana el último ver abajo

Los niveles de aislamiento del estándar SQL permiten más o menos de lo anterior. Más aislamiento es más seguro y más lento, porque hay que esperar más:

Nivel Lectura sucia No repetible Fantasma
READ UNCOMMITTED puede pasar puede pasar puede pasar
READ COMMITTED no puede pasar puede pasar
REPEATABLE READ no no puede pasar (InnoDB las evita en las lecturas comunes)
SERIALIZABLE no no no

MySQL (InnoDB) usa REPEATABLE READ por omisión y se cambia con SET TRANSACTION ISOLATION LEVEL READ COMMITTED; (o el nivel que se quiera). SQLite es serializable: deja leer a muchos al mismo tiempo, pero escribe uno a la vez. Se ve con dos conexiones a la misma base, la caja A y la caja B:

def dos_cajas(ruta):
    """La caja A está a mitad de una transacción; la caja B mira y trata de escribir."""
    a = sqlite3.connect(ruta, isolation_level=None)
    b = sqlite3.connect(ruta, isolation_level=None, timeout=0)
    sql = "SELECT stock FROM producto WHERE id_producto = 2"
    visto = []
    a.execute("BEGIN")
    a.execute("UPDATE producto SET stock = stock - 1 WHERE id_producto = 2")
    visto.append(("A, dentro de su transacción", a.execute(sql).fetchall()[0][0]))
    visto.append(("B, mientras A no confirma", b.execute(sql).fetchall()[0][0]))
    try:
        b.execute("UPDATE producto SET stock = stock - 1 WHERE id_producto = 2")
    except sqlite3.OperationalError as e:
        visto.append(("B trata de escribir", str(e)))
    a.execute("COMMIT")
    visto.append(("B, después del COMMIT de A", b.execute(sql).fetchall()[0][0]))
    a.close(); b.close()
    return visto

Corrida sobre la base recién creada (12 martillos):

A, dentro de su transacción: 11
B, mientras A no confirma: 12
B trata de escribir: database is locked
B, después del COMMIT de A: 11

A ve su propio cambio; B no lo ve hasta el COMMIT (no hay lectura sucia). B no puede escribir mientras A tiene la transacción abierta: con timeout=0 falla en el acto; con el valor por omisión (5 segundos) espera y reintenta. Por eso una transacción debe ser corta: nunca se deja abierta esperando que el usuario apriete un botón.

La actualización perdida no la evita el motor: la evita cómo escribes. Dos vendedores venden 5 siliconas a la vez. Cada programa lee el stock, calcula en Python y escribe el resultado:

def leer_y_escribir(ruta, id_producto, cantidad):
    """Dos vendedores leen el stock, calculan en Python y escriben el resultado (MAL)."""
    a, b = abrir(ruta), abrir(ruta)
    sql = "SELECT stock FROM producto WHERE id_producto = ?"
    stock_a = a.execute(sql, (id_producto,)).fetchall()[0][0]
    stock_b = b.execute(sql, (id_producto,)).fetchall()[0][0]
    vendidas = 0
    for con, visto in ((a, stock_a), (b, stock_b)):
        if visto >= cantidad:
            con.execute("UPDATE producto SET stock = ? WHERE id_producto = ?",
                        (visto - cantidad, id_producto))
            con.commit()
            vendidas += cantidad
    final = a.execute(sql, (id_producto,)).fetchall()[0][0]
    a.close(); b.close()
    return stock_a, vendidas, final
>>> leer_y_escribir(ruta, 5, 5)
(8, 10, 3)

Había 8 siliconas, se vendieron 10 y el stock dice 3. Los dos leyeron 8, los dos vieron que alcanzaba y los dos escribieron 3: la venta del primero se perdió. Ninguna regla falló, porque 3 es un stock válido. El arreglo es que la condición y el descuento vayan en la misma sentencia, y que la base los haga juntos:

def descontar_en_la_base(ruta, id_producto, cantidad):
    """Los mismos dos vendedores, pero el descuento y la condición van en una sola sentencia."""
    a, b = abrir(ruta), abrir(ruta)
    filas = []
    for con in (a, b):
        cur = con.execute("UPDATE producto SET stock = stock - ? "
                          "WHERE id_producto = ? AND stock >= ?",
                          (cantidad, id_producto, cantidad))
        con.commit()
        filas.append(cur.rowcount)
    sql = "SELECT stock FROM producto WHERE id_producto = ?"
    final = a.execute(sql, (id_producto,)).fetchall()[0][0]
    a.close(); b.close()
    return filas, final
>>> descontar_en_la_base(ruta, 5, 5)
([1, 0], 3)

El primer UPDATE cambió 1 fila; el segundo, 0: cuando le tocó, el stock ya era 3 y la condición stock >= 5 no se cumplió. rowcount le dice al programa que la segunda venta no se hizo y que tiene que avisarle al vendedor. En MySQL, cuando de verdad hay que leer antes de escribir, se usa SELECT ... FOR UPDATE, que bloquea la fila hasta el COMMIT; en SQLite, BEGIN IMMEDIATE, que toma el permiso de escritura desde el comienzo.


5. Índices

La idea. Para encontrar las ventas de silicona, el motor recorre toda la tabla detalle_venta fila por fila. Un índice es una estructura aparte, ordenada por una o más columnas (un árbol B), que le dice dónde están las filas con un valor dado, como el índice alfabético al final de un libro. Con 9 filas no importa; con 2 millones, recorrer todo puede tomar segundos y buscar en el índice, milisegundos.

Cómo saber si una consulta usa un índice. EXPLAIN QUERY PLAN delante de la consulta muestra el plan sin ejecutarla. SCAN es recorrer la tabla completa; SEARCH ... USING INDEX es ir directo.

EXPLAIN QUERY PLAN
SELECT SUM(cantidad) FROM detalle_venta WHERE id_producto = 5;
QUERY PLAN
`--SCAN detalle_venta

detalle_venta ya tiene un índice: el de su clave primaria (id_venta, id_producto), que SQLite crea solo y llama sqlite_autoindex_detalle_venta_1. Pero ese índice está ordenado primero por id_venta: sirve para buscar por venta, no por producto, igual que una guía telefónica ordenada por apellido y nombre no sirve para buscar a todos los «Rosa».

EXPLAIN QUERY PLAN
SELECT SUM(cantidad) FROM detalle_venta WHERE id_venta = 5;
QUERY PLAN
`--SEARCH detalle_venta USING INDEX sqlite_autoindex_detalle_venta_1 (id_venta=?)

Para buscar por producto hace falta un índice que empiece por id_producto:

CREATE INDEX idx_detalle_producto ON detalle_venta(id_producto);
EXPLAIN QUERY PLAN
SELECT SUM(cantidad) FROM detalle_venta WHERE id_producto = 5;
QUERY PLAN
`--SEARCH detalle_venta USING INDEX idx_detalle_producto (id_producto=?)
SELECT SUM(cantidad) FROM detalle_venta WHERE id_producto = 5;
SUM(cantidad)
1

El resultado es el mismo con o sin índice (una silicona vendida, en la venta 3): un índice nunca cambia la respuesta, solo el camino.

Una función sobre la columna anula el índice. Con un índice en venta(fecha), un rango se busca:

CREATE INDEX idx_venta_fecha ON venta(fecha);
EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM venta WHERE fecha BETWEEN '2026-10-05' AND '2026-10-07';
QUERY PLAN
`--SEARCH venta USING COVERING INDEX idx_venta_fecha (fecha>? AND fecha<?)

Pero si la condición transforma la columna, el índice ya no sabe dónde buscar y hay que recorrerlo entero (SCAN):

EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM venta WHERE substr(fecha, 9, 2) = '05';
QUERY PLAN
`--SCAN venta USING COVERING INDEX idx_venta_fecha

Covering (que cubre) significa que el índice tiene todas las columnas que la consulta necesita, así que el motor ni siquiera abre la tabla. Aun así, SCAN recorre todo.

Cuándo ayuda un índice.

Cuándo estorba o no sirve.

En MySQL, EXPLAIN SELECT ... devuelve una tabla con una fila por tabla de la consulta. Las columnas que hay que mirar: type (ALL es recorrer todo; ref, eq_ref o range es usar un índice), key (el índice elegido, o NULL) y rows (cuántas filas estima leer). EXPLAIN ANALYZE (desde la 8.0.18) además ejecuta la consulta y muestra los tiempos reales. El CREATE INDEX es igual en los dos motores.


6. La base dentro de un programa: Python y sqlite3

Nadie registra ventas escribiendo SQL en una consola: lo hace el programa de la caja. Python trae el módulo sqlite3; para MySQL se instala un conector (mysql-connector-python) que se usa casi igual.

import sqlite3
def abrir(ruta):
    con = sqlite3.connect(ruta)
    con.execute("PRAGMA foreign_keys = ON")
    return con
def stock_bajo(con, minimo):
    cur = con.cursor()
    cur.execute("SELECT nombre, stock FROM producto WHERE stock < ? ORDER BY stock", (minimo,))
    return cur.fetchall()
>>> con = abrir("ferreteria.db")
>>> stock_bajo(con, 30)
[('Silicona transparente', 8), ('Martillo carpintero 16 oz', 12), ('Brocha 2 pulgadas', 25)]

(Corrido sobre la base recién creada, antes de la venta 6.)

Pieza Qué es
sqlite3.connect(ruta) abre la base (la crea si no existe) y devuelve la conexión
con.cursor() un cursor: el que ejecuta y recorre resultados; con.execute(...) crea uno por ti
cur.execute(sql, parametros) ejecuta una sentencia; cada ? se reemplaza por un parámetro
cur.fetchall() · cur.fetchone() trae todas las filas, como lista de tuplas · trae la siguiente
cur.rowcount cuántas filas cambió el último INSERT, UPDATE o DELETE
cur.lastrowid el id que se le asignó a la última fila insertada
con.commit() · con.rollback() confirma · deshace la transacción abierta
with con: al salir del bloque hace commit(); si hubo una excepción, rollback()
con.close() cierra; lo no confirmado se pierde

Las transacciones en sqlite3. Con la configuración por omisión, Python abre solo una transacción antes de cada INSERT, UPDATE o DELETE, y la deja abierta hasta que el programa llama a commit() o rollback(). Si olvidas el commit(), los cambios se pierden al cerrar. La forma segura es with con:, que confirma o deshace por ti. Con isolation_level=None (como en dos_cajas) Python no abre nada solo y tú escribes BEGIN y COMMIT.

Los parámetros (?). (minimo,) es una tupla de un elemento (la coma es obligatoria). El valor viaja aparte del texto del SQL: la base recibe la consulta con un hueco y el valor para el hueco. Nunca se pega el valor dentro del texto. La sección 7 muestra por qué.

En MySQL el conector usa %s en vez de ? (cur.execute("... WHERE stock < %s", (minimo,))) y también necesita con.commit().


7. Inyección SQL: por qué nunca se arma el SQL pegando texto

Una búsqueda por nombre escrita pegando el texto del usuario dentro del SQL:

def buscar_inseguro(con, texto):
    sql = "SELECT nombre, precio FROM producto WHERE nombre = '" + texto + "'"
    return con.execute(sql).fetchall()

Con un nombre normal funciona:

>>> buscar_inseguro(con, "Cinta aisladora")
[('Cinta aisladora', 1290)]

Ahora alguien escribe en el buscador x' OR '1'='1. El texto se pega y la consulta que llega a la base es SELECT nombre, precio FROM producto WHERE nombre = 'x' OR '1'='1': la comilla del usuario cerró la del programa, y la condición '1'='1' es verdadera para toda fila.

>>> buscar_inseguro(con, "x' OR '1'='1")
[('Tornillo volcanita 6x1 (caja 100)', 2990), ('Martillo carpintero 16 oz', 8490), ('Cinta aisladora', 1290), ('Brocha 2 pulgadas', 1890), ('Silicona transparente', 4590), ('Guantes de trabajo', 3290)]

Acá «solo» se ve el catálogo completo. En una tabla de clientes son los RUT y teléfonos de todos; en un UPDATE o un DELETE, son todas las filas (ejercicio 12.8 y caso). Eso es la inyección SQL: el dato del usuario se convierte en código.

El arreglo: parámetros.

def buscar(con, texto):
    sql = "SELECT nombre, precio FROM producto WHERE nombre = ?"
    return con.execute(sql, (texto,)).fetchall()
>>> buscar(con, "x' OR '1'='1")
[]

Con ?, el texto completo x' OR '1'='1 se busca como un nombre, con comillas y todo. No hay ningún producto que se llame así: lista vacía. La base nunca lo interpreta como SQL.

¿Y '; DROP TABLE producto; --? El método execute de sqlite3 acepta una sola sentencia, así que el ataque clásico de agregar una segunda se detiene:

>>> buscar_inseguro(con, "x'; DROP TABLE producto; --")
ProgrammingError: You can only execute one statement at a time.

No es una defensa: executescript sí ejecuta varias, otros conectores también, y la inyección de una sola sentencia (como OR '1'='1') igual funciona. La única defensa de verdad son los parámetros.

Hazlo No lo hagas
con.execute("... WHERE id = ?", (valor,)) "... WHERE id = " + valor
con.execute("... LIKE ?", ("%" + texto + "%",)) f"... LIKE '%{texto}%'"
validar que un id sea número además de usar ? creer que escapar comillas a mano basta

Los parámetros reemplazan valores, no nombres de tablas ni de columnas. Si el programa tiene que elegir una columna (para ordenar, por ejemplo), la elige de una lista cerrada escrita en el programa, nunca del texto del usuario.


8. Ejemplo resuelto · La venta desde la caja

El problema. La ferretería quiere que la caja registre una venta con una sola llamada: se le pasan el número de venta, la fecha, el vendedor y las líneas (producto, cantidad). Si cualquier línea no se puede vender, no se registra nada y el vendedor recibe el motivo.

Paso 1 · En palabras. Dentro de una transacción: insertar la venta; por cada línea, insertar el detalle con el precio vigente del producto y descontar el stock. Si algo falla, deshacer todo. Los datos del usuario van siempre como parámetros.

Paso 2 · El código.

def vender(con, id_venta, fecha, vendedor, lineas):
    """Registra una venta con sus líneas y descuenta el stock: todo o nada.
    lineas es una lista de (id_producto, cantidad)."""
    with con:
        con.execute("INSERT INTO venta VALUES (?, ?, ?)", (id_venta, fecha, vendedor))
        for id_producto, cantidad in lineas:
            cur = con.execute("INSERT INTO detalle_venta "
                              "SELECT ?, id_producto, ?, precio FROM producto "
                              "WHERE id_producto = ?",
                              (id_venta, cantidad, id_producto))
            if cur.rowcount != 1:
                raise ValueError(f"no existe el producto {id_producto}")
            con.execute("UPDATE producto SET stock = stock - ? WHERE id_producto = ?",
                        (cantidad, id_producto))

Tres decisiones del código, línea por línea:

Paso 3 · Una venta que sale bien (sobre la base recién creada): la venta 6 de la sección 3, ahora desde Python.

>>> vender(con, 6, "2026-10-09", "Tomás", [(2, 2), (3, 5)])
>>> con.execute("SELECT id_producto, stock FROM producto WHERE id_producto IN (2, 3)").fetchall()
[(2, 10), (3, 55)]

Paso 4 · Una venta que no alcanza. Diez siliconas, con 8 en stock:

>>> vender(con, 7, "2026-10-09", "Rosa", [(6, 3), (5, 10)])
IntegrityError: CHECK constraint failed: stock >= 0
>>> con.execute("SELECT (SELECT COUNT(*) FROM venta WHERE id_venta = 7), (SELECT stock FROM producto WHERE id_producto = 6)").fetchall()
[(0, 30)]

Paso 5 · Un producto que no existe.

>>> vender(con, 7, "2026-10-09", "Rosa", [(6, 3), (9, 1)])
ValueError: no existe el producto 9
>>> con.execute("SELECT (SELECT COUNT(*) FROM venta WHERE id_venta = 7), (SELECT stock FROM producto WHERE id_producto = 6)").fetchall()
[(0, 30)]

En los dos fracasos no hay venta 7 y los guantes siguen en 30: lo que alcanzó a hacerse antes del error (la venta, la línea de guantes, su descuento) se deshizo.

Cómo se lee.


9. Errores típicos

Error Cómo evitarlo
Creer que un error dentro de la transacción la deshace sola, y hacer COMMIT igual un error deshace solo esa sentencia; quien abrió la transacción hace ROLLBACK (o usa with con:)
Sentencias de una misma operación sin transacción (cada una se confirma sola) BEGIN ... COMMIT, o with con: en Python
Olvidar con.commit() en Python: los cambios desaparecen al cerrar with con:; revisa con otra conexión que el dato quedó
Olvidar PRAGMA foreign_keys = ON en SQLite: las claves foráneas no se revisan ponlo al abrir cada conexión (función abrir)
Leer, calcular en el programa y escribir el resultado (actualización perdida) UPDATE ... SET stock = stock - ? WHERE ... AND stock >= ? y revisar rowcount
Dejar una transacción abierta esperando al usuario: los demás quedan bloqueados la transacción empieza cuando ya están todos los datos y termina enseguida
Armar el SQL pegando texto del usuario (inyección SQL) parámetros ? (o %s en MySQL) para todo valor
Indexar todas las columnas «por si acaso» cada índice encarece cada escritura; indexa lo que el plan muestra como SCAN en consultas frecuentes
Esperar que un índice compuesto (a, b) sirva para buscar por b sirve para a o para a y b; para b sola hace falta otro índice
Una función sobre la columna indexada (substr(fecha, ...)) escribe la condición sobre la columna tal cual: un rango BETWEEN
INSERT ... SELECT con una condición que no encuentra filas: inserta cero sin error revisa rowcount

10. Práctica

Diez ejercicios: 12.1 a 12.4 básicos, 12.5 a 12.8 intermedios, 12.9 y 12.10 de desafío. Los que dicen «ferretería» siguen la sesión de u12_transacciones.sql (después de la venta 6 y de los dos índices de la sección 5); los que dicen «caso» usan la copia de la base del caso después de la sección 12 (122 pedidos, con los índices de clientes y pedidos ya creados). Las pautas están en la sección 11.

12.1 · ¿Qué letra de ACID? (básico). Di qué propiedad protege (o falta) en cada situación. (a) Se corta la luz un segundo después de que la caja mostró «venta registrada» y, al volver, la venta está. (b) Un programa que traspasa saldo entre dos cuentas se cae entre el abono y el cargo, y al volver ninguna de las dos cambió. (c) Mientras el gerente corre el informe de ventas del día, una caja registra una venta; el informe muestra el total de antes o el de después, pero nunca uno a medias. (d) Una transacción que dejaría un stock negativo no se puede confirmar.

12.2 · Predice y corre (básico, ferretería). Predice qué muestran las dos consultas y después córrelo:

BEGIN;
UPDATE producto SET precio = precio + 100;
DELETE FROM detalle_venta WHERE id_venta = 1;
SELECT (SELECT COUNT(*) FROM detalle_venta) AS lineas,
       (SELECT precio FROM producto WHERE id_producto = 1) AS precio_tornillo;
ROLLBACK;

y de nuevo la misma consulta.

12.3 · Lee dos planes (básico, caso). Corre EXPLAIN QUERY PLAN de estas dos consultas sobre detalle_pedido y explica por qué una busca y la otra recorre, si las dos filtran por una columna de la clave primaria:

EXPLAIN QUERY PLAN
SELECT * FROM detalle_pedido WHERE id_pedido = 40;
EXPLAIN QUERY PLAN
SELECT * FROM detalle_pedido WHERE id_producto = 3;

12.4 · La clave foránea que no se revisa (básico, ferretería). (a) ¿Qué responde SQLite a INSERT INTO detalle_venta VALUES (6, 9, 1, 1000); con PRAGMA foreign_keys = ON, y por qué? (b) ¿Qué pasaría en una conexión abierta sin ese PRAGMA? (c) ¿Por qué no basta con ponerlo una vez en el script que creó la base?

12.5 · COMMIT después del error (intermedio, ferretería). Repite la venta 7 de la sección 3 (guantes y siliconas), pero cuando el último UPDATE falle escribe COMMIT en vez de ROLLBACK. ¿Qué queda en la base? ¿Qué reglas del negocio quedan rotas aunque ninguna restricción haya fallado? ¿Cómo se arregla después?

12.6 · ¿Qué índice crearías? (intermedio, caso). Estampa Sur corre muy seguido dos consultas: «los pedidos de un cliente» (WHERE id_cliente = 12) y «los pedidos pendientes» (WHERE estado = 'pendiente'). Mira el plan de cada una y cuántos pedidos hay por estado. ¿Conviene un índice nuevo para alguna? Argumenta con el plan y con los conteos.

12.7 · Dos vendedores y los guantes (intermedio, ferretería). Sobre la base recién creada (30 pares de guantes), dos vendedores venden 20 pares cada uno al mismo tiempo. (a) Predice qué devuelve leer_y_escribir(ruta, 6, 20) (stock leído, pares vendidos, stock final) y qué anda mal. (b) Predice descontar_en_la_base(ruta, 6, 20). (c) ¿Qué tiene que hacer el programa del segundo vendedor con lo que le devuelve?

12.8 · Anular una venta (intermedio, ferretería). La pantalla «anular venta» recibe el número como texto y llama a esta función:

def anular_venta_inseguro(con, id_texto):
    with con:
        cur = con.execute("DELETE FROM detalle_venta WHERE id_venta = " + id_texto)
    return cur.rowcount

(a) ¿Qué pasa si alguien escribe 3 OR 1=1? (b) Reescríbela con parámetros y di qué devuelve con esa misma entrada. Pruébalo en una copia de juguete, nunca en la base de verdad.

12.9 · Traspasos en un curso (desafío). Un colegio lleva los fondos de los cursos en una tabla cuenta con CHECK (saldo >= 0): el 4.° A tiene $85.000, el 4.° B $42.500 y el centro de padres $120.000. (a) Escribe transferir(con, origen, destino, monto) que abone y después descuente, todo o nada. (b) Pruébala con $50.000 del 4.° B al 4.° A y con $30.000 del centro de padres al 4.° B: ¿cómo quedan los saldos y el total? (c) Corre la misma idea sin transacción (cada sentencia se confirma sola) traspasando $80.000 del 4.° B al 4.° A. ¿Qué pasa con el total? (d) ¿Qué invariante sirve para detectar el problema?

12.10 · Ventas de la segunda quincena (desafío, caso). El dueño pide seguido las ventas entre el 16-03-2026 y el 31-03-2026. (a) Mira el plan de la consulta con p.fecha BETWEEN '2026-03-16' AND '2026-03-31'. (b) Crea un índice que la ayude y mira el plan de nuevo. (c) Un compañero propone filtrar con substr(p.fecha, 9, 2) >= '16': ¿usa el índice? (d) ¿Cuánto vendió esa quincena? (e) Lista los índices que quedaron en la base y di cuántos índices tiene que actualizar el INSERT de un pedido nuevo.


11. Pautas

12.1. (a) Durabilidad: lo confirmado sobrevive al corte. (b) Atomicidad: el traspaso se hizo entero o nada; como no terminó, nada. (c) Aislamiento: el informe no ve la venta a medio registrar. (d) Consistencia: ninguna transacción confirmada deja la base contra sus reglas.

12.2. Dentro de la transacción se ven los cambios propios: había 11 líneas (las 9 originales y las 2 de la venta 6) y se borraron las 2 de la venta 1; el tornillo sube a $3.090:

lineas precio_tornillo
9 3090

Después del ROLLBACK, todo vuelve (11 líneas, contando las dos de la venta 6, y el precio original):

SELECT (SELECT COUNT(*) FROM detalle_venta) AS lineas,
       (SELECT precio FROM producto WHERE id_producto = 1) AS precio_tornillo;
lineas precio_tornillo
11 2990

Ojo con el UPDATE sin WHERE: cambia todos los precios. Dentro de una transacción se deshace; fuera de ella, no hay vuelta atrás.

12.3.

QUERY PLAN
`--SEARCH detalle_pedido USING INDEX sqlite_autoindex_detalle_pedido_1 (id_pedido=?)
QUERY PLAN
`--SCAN detalle_pedido

La clave primaria es (id_pedido, id_producto) y su índice está ordenado primero por id_pedido. Buscar por id_pedido es buscar por la primera columna: sirve. Buscar solo por id_producto es buscar por la segunda: las filas de un producto están repartidas por todo el índice, y el motor recorre la tabla.

12.4. (a)

FOREIGN KEY constraint failed

No existe el producto 9 en producto. (b) Sin el PRAGMA, SQLite inserta la línea sin reclamar: queda un detalle que apunta a un producto inexistente. (c) Porque en SQLite la revisión de claves foráneas es una opción de la conexión, no de la base: cada programa que abre la base tiene que pedirla. Por eso la función abrir lo hace siempre.

12.5. La sesión es la de la sección 3, con COMMIT al final:

BEGIN;
INSERT INTO venta VALUES (7, '2026-10-09', 'Rosa');
INSERT INTO detalle_venta VALUES (7, 6, 3, 3290);
UPDATE producto SET stock = stock - 3 WHERE id_producto = 6;
INSERT INTO detalle_venta VALUES (7, 5, 10, 4590);
UPDATE producto SET stock = stock - 10 WHERE id_producto = 5;
CHECK constraint failed: stock >= 0
COMMIT;
SELECT d.id_producto, d.cantidad, p.stock
FROM detalle_venta d JOIN producto p ON p.id_producto = d.id_producto
WHERE d.id_venta = 7;
id_producto cantidad stock
5 10 8
6 3 27

Queda la venta 7 con sus dos líneas: los guantes descontados (27) y una línea de 10 siliconas con el stock de silicona intacto (8). Toda fila cumple sus restricciones, pero se rompieron dos reglas que no están escritas en la base: «lo vendido se descuenta del stock» y «no se vende lo que no hay». Una vez confirmado, el ROLLBACK ya no sirve: hay que escribir sentencias que compensen (borrar la línea de silicona o rebajarla a 8 y descontarlas) en una transacción nueva, y averiguar si al cliente se le cobraron las 10.

12.6. Los pedidos de un cliente ya usan un índice (el que creó el caso integrador):

EXPLAIN QUERY PLAN
SELECT * FROM pedido WHERE id_cliente = 12;
QUERY PLAN
`--SEARCH pedido USING INDEX idx_pedido_cliente (id_cliente=?)

Los pendientes recorren la tabla:

EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM pedido WHERE estado = 'pendiente';
QUERY PLAN
`--SCAN pedido
SELECT estado, COUNT(*) AS pedidos FROM pedido GROUP BY estado ORDER BY pedidos DESC;
estado pedidos
entregado 81
en taller 31
pendiente 10

Con 122 pedidos, recorrer la tabla es instantáneo: no conviene un índice nuevo. Un índice en estado tiene tres valores distintos; para «entregado» apuntaría a dos tercios de la tabla y no ahorraría nada. Solo se justificaría con muchos miles de pedidos y si la consulta frecuente fuera justamente la del estado raro («pendiente», el 8 % de los pedidos). El de id_cliente, en cambio, sí se justifica: cada cliente tiene pocos pedidos y la consulta es diaria.

12.7. (a)

>>> leer_y_escribir(ruta, 6, 20)
(30, 40, 10)

Los dos leyeron 30, los dos vieron que alcanzaba, se vendieron 40 pares con 30 en bodega y el stock dice 10. Una venta se perdió en el stock y hay 10 pares vendidos que no existen. (b)

>>> descontar_en_la_base(ruta, 6, 20)
([1, 0], 10)

El segundo UPDATE cambió 0 filas: cuando le tocó quedaban 10 pares. (c) Revisar rowcount: si es 0, la venta no se hizo, y el programa avisa al vendedor («quedan 10 pares») en vez de imprimir la boleta.

12.8. (a) La sentencia queda DELETE FROM detalle_venta WHERE id_venta = 3 OR 1=1, y 1=1 es verdadero para toda fila. En la copia de juguete (la base recién creada, con 9 líneas) borra todas:

>>> anular_venta_inseguro(con, "3 OR 1=1")
9
>>> con.execute("SELECT COUNT(*) FROM detalle_venta").fetchall()
[(0,)]

(b)

def anular_venta(con, id_texto):
    with con:
        cur = con.execute("DELETE FROM detalle_venta WHERE id_venta = ?", (id_texto,))
    return cur.rowcount
>>> anular_venta(con, "3 OR 1=1")
0

El texto completo se compara con id_venta como un valor: ninguna venta se llama «3 OR 1=1» y no se borra nada. Además conviene validar que la entrada sea un número antes de llamar a la base, para darle al usuario un mensaje claro.

12.9. (a) La tabla y la función (en u12_programa.py):

def crear_cuentas(con):
    with con:
        con.execute("CREATE TABLE cuenta (id INTEGER PRIMARY KEY, titular VARCHAR(30) NOT NULL, "
                    "saldo INTEGER NOT NULL CHECK (saldo >= 0))")
        con.executemany("INSERT INTO cuenta VALUES (?, ?, ?)",
                        [(1, "4.° A", 85000), (2, "4.° B", 42500),
                         (3, "Centro de padres", 120000)])
def transferir(con, origen, destino, monto):
    """Abona y después descuenta; si el descuento falla, el abono se deshace."""
    with con:
        con.execute("UPDATE cuenta SET saldo = saldo + ? WHERE id = ?", (monto, destino))
        con.execute("UPDATE cuenta SET saldo = saldo - ? WHERE id = ?", (monto, origen))

(b)

>>> con.execute("SELECT * FROM cuenta").fetchall()
[(1, '4.° A', 85000), (2, '4.° B', 42500), (3, 'Centro de padres', 120000)]
>>> con.execute("SELECT SUM(saldo) FROM cuenta").fetchall()
[(247500,)]
>>> transferir(con, 2, 1, 50000)
IntegrityError: CHECK constraint failed: saldo >= 0
>>> con.execute("SELECT * FROM cuenta").fetchall()
[(1, '4.° A', 85000), (2, '4.° B', 42500), (3, 'Centro de padres', 120000)]
>>> transferir(con, 3, 2, 30000)
>>> con.execute("SELECT * FROM cuenta").fetchall()
[(1, '4.° A', 85000), (2, '4.° B', 72500), (3, 'Centro de padres', 90000)]
>>> con.execute("SELECT SUM(saldo) FROM cuenta").fetchall()
[(247500,)]

El primer traspaso alcanzó a abonar $50.000 al 4.° A antes de que el cargo fallara (el 4.° B tenía $42.500); with con: deshizo el abono. El segundo pasó. El total sigue en $247.500. (c) Sin transacción:

def transferir_sin_transaccion(ruta, origen, destino, monto):
    con = sqlite3.connect(ruta, isolation_level=None)      # cada sentencia se confirma sola
    try:
        con.execute("UPDATE cuenta SET saldo = saldo + ? WHERE id = ?", (monto, destino))
        con.execute("UPDATE cuenta SET saldo = saldo - ? WHERE id = ?", (monto, origen))
    finally:
        con.close()
>>> transferir_sin_transaccion(ruta, 2, 1, 80000)
IntegrityError: CHECK constraint failed: saldo >= 0
>>> con.execute("SELECT * FROM cuenta").fetchall()
[(1, '4.° A', 165000), (2, '4.° B', 72500), (3, 'Centro de padres', 90000)]
>>> con.execute("SELECT SUM(saldo) FROM cuenta").fetchall()
[(327500,)]

El abono se confirmó solo y el cargo falló: aparecieron $80.000 de la nada y el total subió a $327.500. Cada fila es válida; la suma no. (d) La suma de los saldos no cambia con un traspaso. Revisarla antes y después (o en una conciliación diaria) detecta cualquier traspaso a medias.

12.10. (a) Sin índice en la fecha, el motor recorre el detalle y busca cada pedido por su clave:

EXPLAIN QUERY PLAN
SELECT SUM(d.cantidad * d.precio_unit) AS ventas
FROM detalle_pedido d
JOIN pedido p ON p.id_pedido = d.id_pedido
WHERE p.fecha BETWEEN '2026-03-16' AND '2026-03-31';
QUERY PLAN
|--SCAN d
`--SEARCH p USING INTEGER PRIMARY KEY (rowid=?)

(b)

CREATE INDEX idx_pedido_fecha ON pedido(fecha);
EXPLAIN QUERY PLAN
SELECT SUM(d.cantidad * d.precio_unit) AS ventas
FROM detalle_pedido d
JOIN pedido p ON p.id_pedido = d.id_pedido
WHERE p.fecha BETWEEN '2026-03-16' AND '2026-03-31';
QUERY PLAN
|--SEARCH p USING COVERING INDEX idx_pedido_fecha (fecha>? AND fecha<?)
`--SEARCH d USING INDEX sqlite_autoindex_detalle_pedido_1 (id_pedido=?)

El plan se dio vuelta: ahora parte por los pedidos del rango (en el índice de fecha) y, para cada uno, busca sus líneas por la clave primaria del detalle. Ya no recorre nada. (c) No:

EXPLAIN QUERY PLAN
SELECT SUM(d.cantidad * d.precio_unit) AS ventas
FROM detalle_pedido d
JOIN pedido p ON p.id_pedido = d.id_pedido
WHERE substr(p.fecha, 9, 2) >= '16';
QUERY PLAN
|--SCAN d
`--SEARCH p USING INTEGER PRIMARY KEY (rowid=?)

Es el plan de antes del índice. Además la condición está mal: toma el día 16 o más de cualquier mes (los pedidos de abril también, si caen después del 15). (d)

SELECT SUM(d.cantidad * d.precio_unit) AS ventas
FROM detalle_pedido d
JOIN pedido p ON p.id_pedido = d.id_pedido
WHERE p.fecha BETWEEN '2026-03-16' AND '2026-03-31';
ventas
18034530

La segunda quincena de marzo vendió $18.034.530. (e)

SELECT name AS indice, tbl_name AS tabla
FROM sqlite_master
WHERE type = 'index'
ORDER BY tbl_name, name;
indice tabla
idx_cliente_tienda cliente
sqlite_autoindex_detalle_pedido_1 detalle_pedido
idx_pedido_cliente pedido
idx_pedido_fecha pedido
sqlite_autoindex_producto_1 producto
sqlite_autoindex_suministra_1 suministra
sqlite_autoindex_tienda_1 tienda

pedido tiene dos índices propios (idx_pedido_cliente e idx_pedido_fecha) además de la tabla, que está ordenada por su INTEGER PRIMARY KEY y no necesita un índice aparte. Un pedido nuevo escribe en la tabla y en esos dos índices: tres escrituras en vez de una. Con unos pocos pedidos al día es un costo despreciable a cambio de que el informe sea inmediato; en una tabla que recibe miles de inserciones por segundo, cada índice se discute.


12. Caso integrador · Estampa Sur registra un pedido

Este caso sigue a Estampa Sur por todo el ramo. Hasta la unidad 11 la base solo se leyó. Acá se escribe: un pedido nuevo entra como transacción, otro falla a la mitad y no deja rastro, el mismo registro se hace desde Python, y la consulta de ventas por tienda recibe sus índices. Todo corre sobre una copia de datos/estampa_sur.db con sql/u12_caso.sql.

El punto de partida. Se activan las claves foráneas, se crea la vista de la unidad 11 y se cuenta:

PRAGMA foreign_keys = ON;
CREATE VIEW v_ventas_tienda AS
SELECT t.nombre AS tienda, SUM(d.cantidad * d.precio_unit) AS ventas
FROM detalle_pedido d
JOIN pedido p  ON p.id_pedido = d.id_pedido
JOIN cliente c ON c.id_cliente = p.id_cliente
JOIN tienda t  ON t.id_tienda = c.id_tienda
GROUP BY t.nombre;
SELECT (SELECT COUNT(*) FROM pedido) AS pedidos,
       (SELECT COUNT(*) FROM detalle_pedido) AS lineas,
       (SELECT COUNT(*) FROM pedido WHERE id_cliente = 27) AS pedidos_cliente_27;
pedidos lineas pedidos_cliente_27
120 212 0

El cliente 27 (una persona de la tienda Lagos) es uno de los cuatro sin pedidos de la unidad 10.

El pedido 121, todo o nada. El cliente 27 encarga 4 poleras básicas y 2 polerones. El precio unitario sale de la tabla producto, no se escribe a mano:

BEGIN;
INSERT INTO pedido (id_pedido, id_cliente, fecha, estado) VALUES (121, 27, '2026-04-02', 'pendiente');
INSERT INTO detalle_pedido (id_pedido, id_producto, cantidad, precio_unit)
SELECT 121, id_producto, 4, precio FROM producto WHERE id_producto = 1;
INSERT INTO detalle_pedido (id_pedido, id_producto, cantidad, precio_unit)
SELECT 121, id_producto, 2, precio FROM producto WHERE id_producto = 2;
COMMIT;
SELECT id_pedido, id_producto, cantidad, precio_unit, cantidad * precio_unit AS monto
FROM detalle_pedido
WHERE id_pedido = 121;
id_pedido id_producto cantidad precio_unit monto
121 1 4 3990 15960
121 2 2 9890 19780
SELECT tienda, ventas FROM v_ventas_tienda ORDER BY ventas DESC;
tienda ventas
Valle 7297650
Centro 6951140
Lagos 6170090
Costa 5922540
Puerto 5534370
Cerro 3883390

El pedido vale $35.740 y Lagos pasa de $6.134.350 a $6.170.090. Nadie tocó la vista: se recalculó sola con el pedido nuevo (unidad 11). Las otras cinco tiendas siguen con las cifras de la unidad 10.

El pedido 122 falla a la mitad. El cliente 28 encarga 3 bolsas y, por un error de digitación, 0 gorros:

BEGIN;
INSERT INTO pedido (id_pedido, id_cliente, fecha, estado) VALUES (122, 28, '2026-04-03', 'pendiente');
INSERT INTO detalle_pedido (id_pedido, id_producto, cantidad, precio_unit)
SELECT 122, id_producto, 3, precio FROM producto WHERE id_producto = 4;
INSERT INTO detalle_pedido (id_pedido, id_producto, cantidad, precio_unit)
SELECT 122, id_producto, 0, precio FROM producto WHERE id_producto = 5;
CHECK constraint failed: cantidad > 0

El CHECK (cantidad > 0) del caso (unidad 8) detuvo la línea. Desde adentro, el pedido y la línea de bolsas siguen ahí:

SELECT (SELECT COUNT(*) FROM pedido WHERE id_pedido = 122) AS pedido_122,
       (SELECT COUNT(*) FROM detalle_pedido WHERE id_pedido = 122) AS lineas_122;
pedido_122 lineas_122
1 1
ROLLBACK;
SELECT (SELECT COUNT(*) FROM pedido) AS pedidos,
       (SELECT COUNT(*) FROM detalle_pedido) AS lineas,
       (SELECT COUNT(*) FROM pedido WHERE id_pedido = 122) AS pedido_122;
pedidos lineas pedido_122
121 214 0

Quedan los 121 pedidos con sus 214 líneas (las 212 más las 2 del pedido 121); del 122 no queda nada.

Índices para las ventas de una tienda. Primero, la consulta de todas las tiendas:

EXPLAIN QUERY PLAN
SELECT tienda, ventas FROM v_ventas_tienda ORDER BY ventas DESC;
QUERY PLAN
|--CO-ROUTINE v_ventas_tienda
|  |--SCAN d
|  |--SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
|  |--SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
|  |--SEARCH t USING INTEGER PRIMARY KEY (rowid=?)
|  `--USE TEMP B-TREE FOR GROUP BY
|--SCAN v_ventas_tienda
`--USE TEMP B-TREE FOR ORDER BY

CO-ROUTINE es la vista calculándose por dentro. Recorre todo el detalle (SCAN d) y para cada línea busca su pedido, su cliente y su tienda por clave primaria. Esta consulta tiene que leer todas las líneas para sumar las seis tiendas: ningún índice le ahorra eso, y todos los JOIN ya van por clave primaria. No hay nada que indexar.

La pregunta frecuente de cada encargada, en cambio, es la de su tienda:

EXPLAIN QUERY PLAN
SELECT tienda, ventas FROM v_ventas_tienda WHERE tienda = 'Centro';
QUERY PLAN
|--CO-ROUTINE v_ventas_tienda
|  |--SEARCH t USING COVERING INDEX sqlite_autoindex_tienda_1 (nombre=?)
|  |--SCAN d
|  |--SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
|  `--SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
`--SCAN v_ventas_tienda

SQLite metió el filtro dentro de la vista y encuentra la tienda Centro por el índice de su nombre (el UNIQUE de la unidad 8), pero después igual recorre todo el detalle: para ir de la tienda a sus líneas tendría que ir de tienda a cliente por id_tienda y de cliente a pedido por id_cliente, y esas claves foráneas no tienen índice en SQLite. Un índice para el primer tramo:

CREATE INDEX idx_cliente_tienda ON cliente(id_tienda);
EXPLAIN QUERY PLAN
SELECT tienda, ventas FROM v_ventas_tienda WHERE tienda = 'Centro';
QUERY PLAN
|--CO-ROUTINE v_ventas_tienda
|  |--SEARCH t USING COVERING INDEX sqlite_autoindex_tienda_1 (nombre=?)
|  |--SCAN d
|  |--SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
|  `--SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
`--SCAN v_ventas_tienda

El plan no cambió. De nada sirve llegar rápido a los clientes si de los clientes a los pedidos hay que recorrer. Hace falta el camino completo:

CREATE INDEX idx_pedido_cliente ON pedido(id_cliente);
EXPLAIN QUERY PLAN
SELECT tienda, ventas FROM v_ventas_tienda WHERE tienda = 'Centro';
QUERY PLAN
|--CO-ROUTINE v_ventas_tienda
|  |--SEARCH t USING COVERING INDEX sqlite_autoindex_tienda_1 (nombre=?)
|  |--SEARCH c USING COVERING INDEX idx_cliente_tienda (id_tienda=?)
|  |--SEARCH p USING COVERING INDEX idx_pedido_cliente (id_cliente=?)
|  `--SEARCH d USING INDEX sqlite_autoindex_detalle_pedido_1 (id_pedido=?)
`--SCAN v_ventas_tienda

Ahora el plan baja por las claves: la tienda por su nombre, sus clientes por id_tienda, los pedidos de cada cliente por id_cliente y las líneas de cada pedido por la clave primaria del detalle. Ya no hay ningún SCAN de una tabla. Con 214 líneas la diferencia no se mide; con cientos de miles de pedidos, la encargada de Centro deja de esperar a que se lean las líneas de las otras cinco tiendas. La consulta de todas las tiendas, como se anticipó, sigue igual:

EXPLAIN QUERY PLAN
SELECT tienda, ventas FROM v_ventas_tienda ORDER BY ventas DESC;
QUERY PLAN
|--CO-ROUTINE v_ventas_tienda
|  |--SCAN d
|  |--SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
|  |--SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
|  |--SEARCH t USING INTEGER PRIMARY KEY (rowid=?)
|  `--USE TEMP B-TREE FOR GROUP BY
|--SCAN v_ventas_tienda
`--USE TEMP B-TREE FOR ORDER BY

Y la respuesta es la misma de siempre:

SELECT tienda, ventas FROM v_ventas_tienda WHERE tienda = 'Centro';
tienda ventas
Centro 6951140

En MySQL estos dos índices no habría que crearlos: InnoDB indexa cada clave foránea al declararla.

El mismo registro desde Python. La tienda no va a escribir BEGIN en una consola. La función del caso (en u12_programa.py) deja que la base asigne el número de pedido y toma los precios de producto:

def registrar_pedido(con, id_cliente, fecha, lineas):
    """Registra un pedido de Estampa Sur con su detalle, todo o nada.
    lineas es una lista de (id_producto, cantidad); el precio sale de la tabla producto."""
    with con:
        cur = con.execute("INSERT INTO pedido (id_cliente, fecha, estado) "
                          "VALUES (?, ?, 'pendiente')", (id_cliente, fecha))
        id_pedido = cur.lastrowid
        for id_producto, cantidad in lineas:
            cur = con.execute("INSERT INTO detalle_pedido "
                              "(id_pedido, id_producto, cantidad, precio_unit) "
                              "SELECT ?, id_producto, ?, precio FROM producto "
                              "WHERE id_producto = ?",
                              (id_pedido, cantidad, id_producto))
            if cur.rowcount != 1:
                raise ValueError(f"no existe el producto {id_producto}")
    return id_pedido

El pedido del cliente 28, primero con el mismo error y después corregido (1 gorro):

>>> registrar_pedido(con, 28, "2026-04-03", [(4, 3), (5, 0)])
IntegrityError: CHECK constraint failed: cantidad > 0
>>> registrar_pedido(con, 28, "2026-04-03", [(4, 3), (5, 1)])
122

El primer intento no dejó nada: por eso el segundo recibe el número 122 y no el 123. Al final:

SELECT (SELECT COUNT(*) FROM pedido) AS pedidos,
       (SELECT COUNT(*) FROM detalle_pedido) AS lineas,
       (SELECT COUNT(*) FROM cliente c
        WHERE NOT EXISTS (SELECT 1 FROM pedido p WHERE p.id_cliente = c.id_cliente)) AS clientes_sin_pedidos;
pedidos lineas clientes_sin_pedidos
122 216 2
SELECT tienda, ventas FROM v_ventas_tienda ORDER BY ventas DESC;
tienda ventas
Valle 7308310
Centro 6951140
Lagos 6170090
Costa 5922540
Puerto 5534370
Cerro 3883390

El pedido 122 vale $10.660 (3 bolsas y 1 gorro) y el cliente 28 es de la tienda Valle, que pasa de $7.297.650 a $7.308.310. Quedan 2 clientes sin pedidos (el 29 y el 30), y cada peso nuevo de la vista corresponde exactamente a un pedido confirmado.

La inyección, en una copia de juguete. La pantalla de despacho marca un pedido como entregado a partir del número que escribe el operador. Con el SQL armado pegando texto:

def marcar_entregado_inseguro(con, id_texto):
    with con:
        sql = "UPDATE pedido SET estado = 'entregado' WHERE id_pedido = " + id_texto
        cur = con.execute(sql)
    return cur.rowcount
>>> marcar_entregado_inseguro(con, "5 OR 1=1")
120
>>> sorted(con.execute("SELECT estado, COUNT(*) FROM pedido GROUP BY estado").fetchall())
[('entregado', 120)]

Los 120 pedidos de la copia quedaron «entregados», incluidos los 31 que estaban en el taller y los 8 pendientes. Por eso se prueba en una copia de juguete y nunca en la base del caso. Con parámetros, sobre otra copia:

def marcar_entregado(con, id_texto):
    with con:
        sql = "UPDATE pedido SET estado = 'entregado' WHERE id_pedido = ?"
        cur = con.execute(sql, (id_texto,))
    return cur.rowcount
>>> marcar_entregado(con, "5 OR 1=1")
0
>>> marcar_entregado(con, "5")
1

La entrada maliciosa no calza con ningún pedido; la normal marca exactamente uno.

La cadena. El verificador crea la base del caso en una carpeta temporal con el mismo script (datos/estampa_sur.db no se toca), comprueba que parte de los 120 pedidos, las 212 líneas y las ventas por tienda del caso, corre u12_caso.sql y las funciones de Python, y exige que Lagos y Valle suban exactamente lo registrado y las otras cuatro tiendas no se muevan (CADENA: OK).

Dónde termina el ramo. Estampa Sur partió en la unidad 1 como una planilla que ya no alcanzaba. Ahora tiene un diagrama, ocho tablas normalizadas con sus claves, consultas que responden las preguntas del dueño, vistas para las frecuentes, pedidos que entran enteros o no entran, índices donde el plan los pide, y un programa que escribe en la base sin abrirle la puerta a nadie.


13. Para seguir

En los libros de referencia del área. El programa del ramo no fija bibliografía. Elmasri y Navathe, Fundamentos de sistemas de bases de datos, y Silberschatz, Korth y Sudarshan, Fundamentos de bases de datos, tienen capítulos completos de transacciones, control de concurrencia, recuperación e indexación, con la teoría que esta unidad deja como intuición (planificaciones serializables, bloqueo en dos fases, árboles B+).

Abiertos y gratis.

Con el tutor. La carpeta tutor/ trae el contexto de este ramo. Pégalo al inicio de la conversación con cualquier IA y pídele, por ejemplo: «Dame una función de Python que registre algo en dos tablas con un error de transacción escondido y otro de inyección SQL; no me digas dónde están hasta que yo los encuentre».