← CC3088
Object Relational Mapping

ORM

Semestre 01, 2026

Motivación

El problema sin ORM

Una aplicación necesita buscar un producto por su ID.

Sin ORM · visión general

import psycopg2

conn = psycopg2.connect("dbname=tienda user=app password=clave")
cursor = conn.cursor()

cursor.execute(
    "SELECT id, nombre, precio, stock FROM productos WHERE id = %s",
    (producto_id,)
)
fila = cursor.fetchone()

if fila:
    producto = {
        "id": fila[0],
        "nombre": fila[1],
        "precio": fila[2],
        "stock": fila[3]
    }

cursor.close()
conn.close()
            
Una consulta simple

Funciona. Pero hay que repetir esta estructura para cada tabla, cada operación.

Lo que se repite siempre

  • Abrir y cerrar la conexión.
  • Escribir SQL para cada operación: SELECT, INSERT, UPDATE, DELETE.
  • Mapear manualmente cada columna a un campo del diccionario u objeto.
  • Gestionar errores y transacciones de forma manual.
  • Sincronizar el esquema SQL con la estructura de datos en el código.

Con 10 tablas, el código de acceso a datos se vuelve masivo.

La mezcla de responsabilidades


# ¿Esto es lógica de negocio o SQL?
def aprobar_pedido(pedido_id):
    cursor.execute(
        "UPDATE pedidos SET estado = 'aprobado', "
        "fecha_aprobacion = NOW() WHERE id = %s",
        (pedido_id,)
    )
    cursor.execute(
        "UPDATE inventario SET reservado = reservado - cantidad "
        "FROM pedido_detalle WHERE pedido_id = %s",
        (pedido_id,)
    )
    conn.commit()
            
Código mezclado

La lógica de negocio y el SQL están entrelazados.

Cambiar la base de datos implica reescribir la lógica.

Concepto

Definición

Object Relational Mapping — Mapeo Objeto-Relacional.

Una capa de abstracción que traduce entre dos mundos.

Los dos mundos

Mundo relacionalMundo orientado a objetos
TablaClase
FilaObjeto / instancia
ColumnaAtributo
Clave primariaIdentificador del objeto
Clave foráneaReferencia a otro objeto
JOINAcceso a objeto relacionado

La idea central

En lugar de escribir SQL, se trabaja con objetos del lenguaje de programación. El ORM traduce automáticamente.

Sin ORM


cursor.execute(
    "SELECT * FROM productos WHERE precio < %s",
    (100,)
)
                

Con ORM


productos = Producto.query.filter(
    Producto.precio < 100
).all()
                

Mismo resultado, distinta abstracción.

El mapeo en la práctica

Se define la tabla como una clase:


class Producto(Base):
    __tablename__ = "productos"

    id      = Column(Integer, primary_key=True)
    nombre  = Column(String(200), nullable=False)
    precio  = Column(Numeric(10, 2), nullable=False)
    stock   = Column(Integer, default=0)
        

A partir de esa definición, el ORM sabe cómo construir el SQL necesario.

Ecosistema

ORMs más usados

SQLAlchemy Python

El más completo y flexible. Compatible con PostgreSQL, MySQL, SQLite, Oracle y más.

Django ORM Python

Integrado en Django. Muy productivo: genera migraciones y panel de administración automáticamente.

Eloquent PHP / Laravel

Patrón Active Record. Sintaxis fluida y expresiva. Ampliamente usado en aplicaciones web.

Hibernate Java

El más maduro del ecosistema Java. Base de JPA. Estándar en aplicaciones empresariales.

Prisma TypeScript / Node.js

ORM moderno con cliente tipado. Errores detectados en tiempo de compilación.

Tabla comparativa — Python y PHP

ORMLenguajeEstiloCompatibilidad principal
SQLAlchemyPythonData MapperPostgreSQL, MySQL, SQLite
Django ORMPythonActive RecordPostgreSQL, MySQL, SQLite
EloquentPHPActive RecordMySQL, PostgreSQL, SQLite

Tabla comparativa — Java y TypeScript

ORMLenguajeEstiloCompatibilidad principal
HibernateJavaData MapperPostgreSQL, MySQL, Oracle
PrismaTypeScriptData MapperPostgreSQL, MySQL, SQLite
SQLAlchemy

SQLAlchemy con PostgreSQL y MySQL

Configuración de conexión


from sqlalchemy import create_engine

# PostgreSQL
engine = create_engine("postgresql+psycopg2://usuario:clave@localhost/tienda")

# MySQL
engine = create_engine("mysql+pymysql://usuario:clave@localhost/tienda")
        

Solo cambia la cadena de conexión — el código ORM es idéntico para ambos motores.

Definir modelos con relación


from sqlalchemy import Column, Integer, String, Numeric, ForeignKey
from sqlalchemy.orm import DeclarativeBase, relationship

class Base(DeclarativeBase):
    pass

class Categoria(Base):
    __tablename__ = "categorias"
    id     = Column(Integer, primary_key=True)
    nombre = Column(String(100), nullable=False, unique=True)
    productos = relationship("Producto", back_populates="categoria")

class Producto(Base):
    __tablename__ = "productos"
    id           = Column(Integer, primary_key=True)
    nombre       = Column(String(200), nullable=False)
    precio       = Column(Numeric(10, 2), nullable=False)
    stock        = Column(Integer, default=0)
    categoria_id = Column(Integer, ForeignKey("categorias.id"))
    categoria    = relationship("Categoria", back_populates="productos")
        
INSERT · código ORM

from sqlalchemy.orm import Session

with Session(engine) as session:
    nuevo = Producto(
        nombre="Laptop",
        precio=5000.00,
        stock=10,
        categoria_id=1
    )
    session.add(nuevo)
    session.commit()
            
Insertar un registro

Se crea un objeto Python normal. session.add() lo registra. session.commit() genera y ejecuta el SQL.

INSERT · SQL generado

from sqlalchemy.orm import Session

with Session(engine) as session:
    nuevo = Producto(
        nombre="Laptop",
        precio=5000.00,
        stock=10,
        categoria_id=1
    )
    session.add(nuevo)
    session.commit()
            

SQL generado internamente:


INSERT INTO productos
  (nombre, precio, stock, categoria_id)
VALUES
  ('Laptop', 5000.00, 10, 1)
                

Consultar registros


with Session(engine) as session:

    # Todos los productos
    todos = session.query(Producto).all()

    # Por ID
    uno = session.get(Producto, 5)

    # Con filtros y orden
    disponibles = session.query(Producto).filter(
        Producto.stock > 0,
        Producto.precio < 2000
    ).order_by(Producto.precio).all()
        
UPDATE · código ORM

with Session(engine) as session:
    producto = session.get(Producto, 5)

    if producto:
        producto.precio = 4500.00
        producto.stock = 15
        session.commit()
            
Solo modificar los atributos

El ORM detecta qué cambió y genera solo el UPDATE necesario.

UPDATE · SQL generado

with Session(engine) as session:
    producto = session.get(Producto, 5)

    if producto:
        producto.precio = 4500.00
        producto.stock = 15
        session.commit()
            

SQL generado:


UPDATE productos
SET precio = 4500.00, stock = 15
WHERE productos.id = 5
                

Solo se actualiza lo que cambió.

Relaciones: sin escribir JOINs


with Session(engine) as session:
    producto = session.get(Producto, 5)

    # Sin escribir un JOIN
    print(producto.nombre)
    print(producto.categoria.nombre)
            

SQL ejecutado al acceder a .categoria:


SELECT categorias.id, categorias.nombre
FROM categorias
WHERE categorias.id = 1
                
Eloquent

Eloquent con MySQL y PostgreSQL

Definir un modelo


namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Producto extends Model
{
    protected $table = 'productos';

    protected $fillable = [
        'nombre', 'precio', 'stock', 'categoria_id',
    ];

    public function categoria()
    {
        return $this->belongsTo(Categoria::class);
    }
}
        

Operaciones CRUD


// Insertar
$producto = Producto::create([
    'nombre'       => 'Laptop',
    'precio'       => 5000.00,
    'stock'        => 10,
    'categoria_id' => 1,
]);

// Buscar por ID
$producto = Producto::find(5);

// Con filtros
$baratos = Producto::where('precio', '<', 1000)
                   ->where('stock', '>', 0)
                   ->orderBy('precio')
                   ->get();

// Actualizar y eliminar
$producto->precio = 4500.00;
$producto->save();
$producto->delete();
        

Acceso a relaciones y cambio de motor

Relaciones como atributos


$producto = Producto::find(5);

echo $producto->nombre;
// JOIN automático:
echo $producto->categoria->nombre;
                

Cambiar de motor en .env


# MySQL
DB_CONNECTION=mysql
DB_PORT=3306

# PostgreSQL
DB_CONNECTION=pgsql
DB_PORT=5432
                

El código de los modelos no cambia.

Seguridad

ORM y SQL Injection

El problema central

Construir SQL con concatenación de strings es la causa raíz de SQL Injection.


# Vulnerable — datos del usuario directamente en el SQL
query = f"SELECT * FROM usuarios WHERE email = '{email}'"
        
Los ORMs eliminan ese patrón por diseño. Siempre usan consultas parametrizadas internamente.

Demostración: input malicioso


nombre_buscado = "'; DROP TABLE productos; --"

# Sin ORM — vulnerable
query = f"SELECT * FROM productos WHERE nombre = '{nombre_buscado}'"
# Ejecuta: SELECT * FROM productos WHERE nombre = ''; DROP TABLE productos; --'

# Con ORM — seguro
session.query(Producto).filter(Producto.nombre == nombre_buscado).all()
# Genera: SELECT * FROM productos WHERE nombre = $1
# Parámetro: "'; DROP TABLE productos; --"  (texto, no SQL)
        

La tabla no se elimina. El input malicioso es buscado literalmente como nombre.

Protección en todas las operaciones


# Todo parametrizado automáticamente
session.add(Producto(nombre=nombre, precio=precio))   # INSERT parametrizado
session.query(Producto).filter(Producto.id == id)     # SELECT parametrizado
producto.precio = nuevo_precio; session.commit()       # UPDATE parametrizado
session.delete(producto)                               # DELETE por PK
        

El desarrollador no necesita recordar parametrizar — el ORM lo hace por defecto.

La excepción: raw queries

Cuando se escribe SQL crudo, puede reaparecer la vulnerabilidad.

SQLAlchemy — vulnerable


session.execute(
    text(f"SELECT * FROM productos "
         f"WHERE nombre = '{nombre}'")
)
                

SQLAlchemy — seguro


session.execute(
    text("SELECT * FROM productos "
         "WHERE nombre = :nombre"),
    {"nombre": nombre}
)
                

Eloquent — vulnerable


DB::select(
  "SELECT * FROM productos
   WHERE nombre = '$nombre'"
);
                

Eloquent — seguro


DB::select(
  "SELECT * FROM productos
   WHERE nombre = ?",
  [$nombre]
);
                
Beneficios

Usos

Productividad

CRUD en pocas líneas. La lógica de acceso queda en el modelo.

🔄 Independencia del motor

Mismo código para PostgreSQL, MySQL y SQLite. Cambiar es cuestión de un parámetro.

🔒 Seguridad por defecto

Consultas parametrizadas automáticamente. Protección contra SQL Injection estructural.

📜 Migraciones

Historial de cambios del esquema versionado junto con el código.

🔗 Relaciones como objetos

No hay que escribir JOINs. Se accede a datos relacionados como atributos.

Validaciones

Los modelos definen reglas que se validan antes de llegar a la base de datos.

Resumen: con ORM vs sin ORM

AspectoSin ORMCon ORM
SQL InjectionManual — depende del desarrolladorAutomático — parametrizado siempre
Código repetitivoAlto — SQL para cada tablaBajo — el ORM genera el SQL
Cambio de motor DBReescribir todas las consultasCambiar la cadena de conexión
MigracionesScripts SQL manualesGeneradas automáticamente
RelacionesJOINs escritos a manoAcceso como atributos
Limitaciones

Limitaciones del ORM

El ORM no es una solución perfecta para todo.

N+1 · el código

El problema N+1


productos = session.query(Producto).all()   # query 1: trae todos los productos

for producto in productos:
    print(producto.categoria.nombre)        # query 2, 3, 4 ... N+1
        

Parece inocente. Pero el ORM hace una query extra por cada producto al acceder a .categoria.

N+1 · lo que ejecuta

Lo que realmente pasa en la base de datos


-- Query 1: traer los productos
SELECT * FROM productos;                          -- devuelve 1000 filas

-- Query 2: categoría del producto 1
SELECT * FROM categorias WHERE id = 3;
-- Query 3: categoría del producto 2
SELECT * FROM categorias WHERE id = 1;
-- Query 4: categoría del producto 3
SELECT * FROM categorias WHERE id = 3;
-- ...
-- Query 1001: categoría del producto 1000
SELECT * FROM categorias WHERE id = 7;
        

1000 productos = 1001 queries. El ORM las dispara en silencio.

Eager loading · solución

Eager loading: traer todo en una sola query


from sqlalchemy.orm import joinedload

productos = session.query(Producto).options(
    joinedload(Producto.categoria)
).all()

# Ahora esto NO hace queries extra
for producto in productos:
    print(producto.categoria.nombre)
            

SQL generado:


SELECT productos.*, categorias.*
FROM productos
LEFT OUTER JOIN categorias
  ON categorias.id = productos.categoria_id
                

1 query en lugar de 1001.

N+1 · resumen

Lazy vs Eager loading

Estrategia¿Cuándo carga la relación?Queries
Lazy (por defecto) Al acceder al atributo (.categoria) 1 + N
Eager (joinedload) En la misma query inicial con JOIN 1

Lazy loading es cómodo para un objeto. Eager loading es obligatorio para listas.

Abstracción que oculta lo que sucede


# ¿Cuántas queries ejecuta esto?
for pedido in session.query(Pedido).all():
    total = sum(d.cantidad * d.precio for d in pedido.detalles)
        

Puede ejecutar cientos de queries sin que el desarrollador lo note.

Otras limitaciones

Curva de aprendizaje

Los ORMs tienen su propia API. Usarlos bien requiere entender el modelo relacional.

Consultas complejas

Múltiples JOINs, subconsultas, funciones específicas del motor: el SQL manual suele ser más eficiente y claro.

Rendimiento no garantizado

El SQL generado no siempre es óptimo. Hay que revisar qué produce el ORM.

Fuera del alcance del ORM

Procedimientos almacenados, triggers y funciones complejas requieren SQL crudo.

Producción

ORM y base de datos en producción

El ORM no reemplaza el conocimiento de SQL.

Ver el SQL que genera el ORM

Indispensable para depurar problemas de rendimiento.

SQLAlchemy


print(
    session.query(Producto)
    .filter(Producto.precio < 1000)
)
                

Django ORM


print(
    Producto.objects
    .filter(precio__lt=1000)
    .query
)
                

Un ORM mal usado es más lento que SQL directo.

Índices: no son automáticos

El ORM solo crea índice en la clave primaria. Los demás deben definirse explícitamente.


class Producto(Base):
    __tablename__ = "productos"

    id     = Column(Integer, primary_key=True)
    nombre = Column(String(200), index=True)   # crea índice en nombre
    precio = Column(Numeric(10, 2), nullable=False)
        

Sin los índices correctos, las consultas del ORM serán lentas igual que el SQL directo.

La combinación es válida

ORM para las operaciones del día a día, SQL directo para lo que el ORM no maneja bien.


# Llamar a un procedimiento almacenado desde el ORM
session.execute(text("CALL procesar_inventario_diario()"))
        

Las reglas de negocio complejas pueden seguir viviendo en procedimientos almacenados.