Por omisión, perfilar_dbi() mantiene separados dos
resultados porque provienen de universos distintos:
-
resumen_tablacalcula agregados SQL sobre la tabla completa; -
perfil_muestraejecutaperfilar()sobre las filas traídas a memoria.
El resumen SQL usa universo = "tabla_completa" por
omisión. Para tablas grandes se puede pedir
universo = "muestra_motor" junto con
muestra_motor = n: las métricas se calculan sobre una
relación muestreada por el motor, no sobre una tabla traída a R. La
capacidad se sondea antes de medir; PostgreSQL y SQL Server pueden
resolverla con TABLESAMPLE, y otros motores con una función
pseudoaleatoria y el límite de su dialecto. Si ninguna forma es
aceptada, las métricas quedan en NA y
resumen_tabla$cobertura conserva el motivo.
Cada registro de resumen_tabla$sql declara
alcance, universo,
tamano_muestra, fraccion, metodo
y error_esperado. En el universo muestreado,
error_esperado distingue no_estimado —el error
podría calcularse bajo un plan probabilístico, pero no se calculó— de
no_estimable —moda, mediana y cardinalidad observada no
tienen una cota simple sin supuestos adicionales—.
no_aplica indica que no hubo muestreo efectivo. El motivo
queda en resumen_tabla$sql$motivo, junto con el método, el
tamaño y la fracción. n_distintos se conserva como
cardinalidad observada en la muestra, no como una estimación de la
cardinalidad de la tabla completa.
El metadato resumen_tabla$meta$muestreo conserva
tamano_muestra por compatibilidad como tamaño efectivo
solicitado a la consulta. Además publica filas_solicitadas,
el pedido original, y filas_obtenidas, las filas que
devolvió la lectura de perfil_muestra; esta última puede
ser NA cuando el bloque no se solicitó o falló antes de
leer. Si la consulta de la muestra devuelve cero filas, no hay base para
medir las métricas de alcance muestra: quedan en
NA, con estado no_disponible y un motivo que
nombra la muestra vacía. Esto no permite concluir que la columna esté
vacía, por lo que no se publica cero ni se dispara
sin_valores; n conserva el conteo de la tabla
completa. estrategia_distintos es explícita:
"exacta" es el valor por omisión y emite
COUNT(DISTINCT); "aproximada_motor" usa una
función nativa sólo si el motor la acepta; "catalogo" lee
pg_stats.n_distinct en PostgreSQL y lo publica como
estimación, con guardas de herencia y de modos muestreados; en otros
motores queda no_disponible con su motivo; y
"omitida" no emite la consulta. Una estrategia aproximada
sin capacidad no se convierte en exacta. El resultado distingue la
estrategia solicitada, la resuelta y su estado.
Además, id_consulta identifica la consulta de datos que
produjo cada métrica; muestra_id queda reservado para la
relación materializada en el spool cliente. Dos métricas con el mismo
valor vieron exactamente las mismas filas y se pueden comparar
directamente, sin cruzar lote ni
columnas_compartidas. Un NA declara que esa
garantía no se puede hacer; en particular, moda, frecuencia de la moda y
mediana son métricas por columna y no comparten filas con otras.
consulta_id identifica la sentencia que produjo cada
medición y permite formar el grupo de consistencia: las cotas duras sólo
comparan valores con el mismo identificador. La consulta exacta de
distintos trae COUNT(columna) AS n_validos_guard junto a
COUNT(DISTINCT columna); si una capacidad aproximada no
puede traer ese guardián, la cota se declara no comprobable y no se
atribuye una inconsistencia al motor.
El catálogo de la clave primaria se consulta siempre, aunque la
política de costo no necesite cardinalidad. La respuesta queda en
resumen_tabla$meta$clave, con sus columnas,
fuente, motivo, garantia y
estado. La consulta es de metadatos y no recorre la tabla.
La fuente estructural sólo gobierna la medición de cardinalidad cuando
la política lo necesita; no se pierde el conocimiento de una clave
garantizada si estrategia_distintos está omitida o no
disponible. Si la fuente estructural no alcanza y la estrategia no
permite medir, la cardinalidad queda desconocida y se aplica la regla
explícita de la política.
El resumen declara meta$snapshot = FALSE, porque sus
agregados son sentencias separadas. Cuando n_validos y
n_distintos exactos son incoherentes y vienen de grupos
consulta_id distintos, cobertura agrega
alcance_distinto y el motivo conserva ambas sentencias: es
evidencia de que la tabla cambió durante la corrida, no una acusación
contra el motor.
Mezclarlos en una fila, aun con un campo de alcance por resultado, permitiría comparar cantidades como si pertenecieran al mismo perfil. Los dos bloques impiden esa lectura.
Si sólo se necesitan los agregados del motor,
bloque_muestra = "solo_agregados" evita traer filas a R y
deja perfil_muestra = NULL. La cobertura lo declara como
no_solicitado, que no es un fallo. La lectura habitual se
conserva con bloque_muestra = "con_muestra", que es el
valor por omisión.
max_celdas_muestra y max_bytes_muestra
acotan sólo el bloque perfil_muestra: el primero se
resuelve con las columnas del esquema y el segundo con una sonda de
hasta 100 filas, antes de leer el resto. Los agregados SQL conservan su
alcance y la cobertura declara cuál tope redujo la muestra.
Un ejemplo en memoria
Para ejecutar el bloque se necesitan los paquetes opcionales
DBI y RSQLite, ambos declarados en
Suggests. Si falta cualquiera de ellos, knitr omite el
bloque y la viñeta se construye sin error.
La llamada a DBI::dbWriteTable() crea la base de
demostración en memoria. Pertenece a la preparación del ejemplo;
perfilar_dbi() no escribe en la base, no crea tablas
temporales y sólo emite consultas de lectura.
library(lupa)
datos_base <- data.frame(
id = 1:12,
grupo = rep(c("A", "B", "C"), each = 4),
valor = c(10, 12, NA, 14, 20, 22, 24, 26, 30, 32, 34, 36),
fecha = as.character(as.Date("2026-01-01") + 0:11),
stringsAsFactors = FALSE
)
datos_base
#> id grupo valor fecha
#> 1 1 A 10 2026-01-01
#> 2 2 A 12 2026-01-02
#> 3 3 A NA 2026-01-03
#> 4 4 A 14 2026-01-04
#> 5 5 B 20 2026-01-05
#> 6 6 B 22 2026-01-06
#> 7 7 B 24 2026-01-07
#> 8 8 B 26 2026-01-08
#> 9 9 C 30 2026-01-09
#> 10 10 C 32 2026-01-10
#> 11 11 C 34 2026-01-11
#> 12 12 C 36 2026-01-12
conexion <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
creada <- DBI::dbWriteTable(conexion, "entregas", datos_base)
sin_orden <- perfilar_dbi(
conexion, "entregas", muestra = 5
)
perfil <- perfilar_dbi(
conexion, "entregas", muestra = 5, orden_muestra = "id"
)
solo_agregados <- perfilar_dbi(
conexion, "entregas", metricas = "validos",
bloque_muestra = "solo_agregados"
)La conexión y los dos perfiles ya están preparados. Los bloques siguientes consultan ese mismo estado: partir la explicación no implica recrear la base ni volver a medirla.
Cuando el perfil es de una tabla en memoria, perfilar()
proyecta el costo de las celdas antes de iniciar las etapas caras. Desde
100.000 celdas puede mostrar, sólo en una sesion interactiva, un aviso
con la duracion estimada y la fuente de la referencia; en un guion no
interactivo queda en silencio. La proyeccion se conserva en
meta$costo_tabla_ancha. Para la proximidad del vocabulario,
valores de mas de 10.000 caracteres quedan fuera de la distancia
normalizada y la cobertura lo declara con el umbral; Inf es
la forma explicita de recuperar el comportamiento anterior.
Antes de conectarse: qué hace falta para cada motor
lupa tiene dos dependencias obligatorias,
cli y data.table. El paquete del motor y la
biblioteca del sistema que va debajo son cosa de quien instala, y el
error que aparece cuando falta alguna rara vez dice cuál es.
requisitos_motor() # el catálogo entero
requisitos_motor("mariadb") # qué necesita MariaDB, y cómo conseguirloPor cada motor devuelve el paquete de R, la biblioteca del sistema con su nombre en Debian y en Fedora, la salida sin permisos de administrador cuando existe, el dialecto esperado y si está probado contra motor real o sólo esperado.
Eso último importa y conviene leerlo con cuidado: la columna dice si
lupa se probó contra ese motor, no si tu
instalación funciona. Y la comprobación de la biblioteca del sistema es
honesta sobre su propio límite — cuando sólo puede decir «el paquete de
R no está, y si al instalarlo falla la compilación lo que falta es
esto», dice eso, porque comprobar de verdad exigiría intentar
compilar.
La clave primaria que se lee después de esa conexión conserva dos
datos distintos: fuente dice qué catálogo respondió y
garantia dice si el estado consultado alcanza para confiar
en el motor. Oracle sólo queda garantizado con
STATUS = ENABLED y VALIDATED; PostgreSQL
consulta enforced y la validación, y MySQL
enforced. MariaDB, SQL Server, SQLite y DuckDB no ofrecen
en esta lectura un estado comparable, así que allí una clave visible
queda con garantía desconocida. No hay que leer una entrada del catálogo
como prueba de los datos existentes cuando el estado no fue consultable.
Una tabla sin clave declarada queda con
garantia = "no_declarada"; una consulta fallida o un
catálogo cuya visibilidad no se puede establecer queda con
garantia = "desconocida" y su motivo.
El método de impresión muestra esta línea de metadatos junto al resumen. Por ejemplo, una tabla sin clave dice «Clave primaria: no declara clave», mientras que una consulta fallida dice que no se pudo preguntar al catálogo.
Leer el resultado sin memorizar su forma
perfilar() devuelve un perfil plano y
perfilar_dbi() un contenedor con dos componentes: el
segundo puede ser NULL si la muestra no se pidió o no
estuvo disponible. Es una diferencia real —una tabla en memoria y una
tabla remota no son lo mismo— pero convertía la lectura en un acertijo:
perfil$general$filas funciona sobre el primero y devuelve
NULL sobre el segundo.
library(lupa)
en_memoria <- perfilar(datos_administrativos, analizar_dependencias = FALSE)
n_filas(en_memoria)
#> [1] 13
nrow(columnas(en_memoria))
#> [1] 10
nrow(hallazgos(en_memoria))
#> [1] 23
nrow(cobertura(en_memoria))
#> [1] 3
sql_perfil(en_memoria) # NULL: un perfil en memoria no emitió SQL
#> NULLLos mismos cinco nombres sirven para las cuatro formas de salida. Y no inventan lo que no hay: un perfil DBI sin muestra leída, o con la muestra no solicitada, devuelve una tabla de hallazgos vacía con su aviso, no una tabla que aparente que se midió y no había nada.
Los dos bloques posibles del resultado
names(perfil) hace visible la separación que devuelve
perfilar_dbi(). La tabla siguiente compara el alcance de
cada bloque: las filas de origen y la cantidad de campos analíticos, sin
presentarlos como un único perfil.
names(perfil)
#> [1] "resumen_tabla" "perfil_muestra"
solo_agregados$perfil_muestra
#> NULL
solo_agregados$resumen_tabla$cobertura[
solo_agregados$resumen_tabla$cobertura$bloque == "perfil_muestra", ,
drop = FALSE
]
#> bloque elemento estado
#> 1 perfil_muestra entregas no_solicitado
#> motivo
#> 1 No se solicito el perfil de muestra: este resultado contiene unicamente los agregados SQL de la configuracion elegida.
#> como_resolverlo
#> 1 Para obtener diagnosticos sobre valores y hallazgos por fila, volver a perfilar con `bloque_muestra = "con_muestra"`.
#> sql
#> 1 <NA>
data.frame(
bloque = c("resumen_tabla", "perfil_muestra"),
filas_fuente = c(
perfil$resumen_tabla$meta$filas,
perfil$perfil_muestra$meta$filas_analizadas
),
campos_analiticos = c(
ncol(perfil$resumen_tabla$columnas) - 1L,
ncol(perfil$perfil_muestra$columnas) - 1L
),
columnas_del_resultado = c(
ncol(perfil$resumen_tabla$columnas),
ncol(perfil$perfil_muestra$columnas)
)
)
#> bloque filas_fuente campos_analiticos columnas_del_resultado
#> 1 resumen_tabla 12 15 16
#> 2 perfil_muestra 5 112 113
resumen_tabla: agregados sobre la tabla completa
resumen_tabla$columnas contiene quince campos
analíticos, además del nombre de la columna. Son los conteos y agregados
que esta interfaz puede calcular en SQL sin traer la tabla: faltantes,
distintos, moda y resúmenes cuantitativos, entre otros. La salida se
calcula sobre las doce filas completas de entregas.
perfil$resumen_tabla$columnas[, c(
"columna", "n", "n_faltantes", "n_distintos", "minimo", "maximo", "media"
)]
#> columna n n_faltantes n_distintos minimo maximo media
#> 1 id 12 0 12 1 12 6.50000
#> 2 grupo 12 0 3 NA NA NA
#> 3 valor 12 1 11 10 36 23.63636
#> 4 fecha 12 0 12 NA NA NAperfil_muestra$columnas trae 112 campos analíticos
además del nombre de la columna, pero su universo son cinco filas en
este ejemplo. Llamar perfil al resumen SQL afirmaría una
completitud que no tiene: el resumen cubre quince de esos aspectos sobre
doce filas; el perfil cubre sus 111 sobre las cinco filas obtenidas. Las
consultas, estados y motivos de los agregados SQL quedan en
resumen_tabla$sql para que también se vea qué aceptó o
rechazó el motor.
La instrumentación queda activa por omisión y sólo agrega metadatos.
Cada fila de resumen_tabla$sql conserva la métrica que ya
representaba y suma duracion_ms, cpu_ms,
n_filas_resultado, bytes_resultado_r,
consulta_id e id_consulta, además de
etapa y nivel. La duración incluye la
ejecución y la lectura del resultado que DBI entregó a R; el tamaño es
object.size() de ese resultado. cpu_ms es la
suma de user.self y sys.self de
proc.time(): cerca de cero indica espera y cerca de uno al
dividirlo por duracion_ms indica trabajo del cliente. Las
filas que no emitieron consulta dejan esos campos en NA,
que significa «no medido», no 0. Como una consulta puede
producir varias filas (una por columna y métrica), su duración se repite
en ellas: nivel = 1 marca la primera fila de cada
consulta_id y nivel = 2 las repeticiones. Para
sumar tiempos SQL se deben usar sólo las filas de
nivel = 1.
perfil$resumen_tabla$sql[, c(
"metrica", "estado", "duracion_ms", "cpu_ms", "n_filas_resultado",
"bytes_resultado_r", "consulta_id", "id_consulta", "etapa", "nivel"
)]
#> metrica estado duracion_ms cpu_ms n_filas_resultado bytes_resultado_r
#> 1 n calculado 0.4670620 1 1 3192
#> 2 n calculado 0.4670620 1 1 3192
#> 3 n calculado 0.4670620 1 1 3192
#> 4 n calculado 0.4670620 1 1 3192
#> 5 n_validos calculado 0.4670620 1 1 3192
#> 6 n_faltantes calculado 0.4670620 1 1 3192
#> 7 prop_faltantes calculado 0.4670620 1 1 3192
#> 8 n_distintos calculado 0.3683567 0 1 1216
#> 9 tasa_distintos calculado 0.3683567 0 1 1216
#> 10 moda calculado 0.3979206 0 1 1024
#> 11 frecuencia_moda calculado 0.3979206 0 1 1024
#> 12 minimo calculado 0.4670620 1 1 3192
#> 13 maximo calculado 0.4670620 1 1 3192
#> 14 media calculado 0.4670620 1 1 3192
#> 15 n_ceros calculado 0.4670620 1 1 3192
#> 16 n_negativos calculado 0.4670620 1 1 3192
#> 17 mediana calculado 0.4351139 0 1 736
#> 18 desvio calculado 0.4670620 1 1 3192
#> 19 n_validos calculado 0.4670620 1 1 3192
#> 20 n_faltantes calculado 0.4670620 1 1 3192
#> 21 prop_faltantes calculado 0.4670620 1 1 3192
#> 22 n_distintos calculado 0.3683567 0 1 1216
#> 23 tasa_distintos calculado 0.3683567 0 1 1216
#> 24 moda calculado 0.4148483 1 1 1080
#> 25 frecuencia_moda calculado 0.4148483 1 1 1080
#> 26 minimo no_aplica NA NA NA NA
#> 27 maximo no_aplica NA NA NA NA
#> 28 media no_aplica NA NA NA NA
#> 29 n_ceros no_aplica NA NA NA NA
#> 30 n_negativos no_aplica NA NA NA NA
#> 31 mediana no_aplica NA NA NA NA
#> 32 desvio no_aplica NA NA NA NA
#> 33 n_validos calculado 0.4670620 1 1 3192
#> 34 n_faltantes calculado 0.4670620 1 1 3192
#> 35 prop_faltantes calculado 0.4670620 1 1 3192
#> 36 n_distintos calculado 0.3950596 1 1 1216
#> 37 tasa_distintos calculado 0.3950596 1 1 1216
#> 38 moda calculado 0.5323887 1 1 1024
#> 39 frecuencia_moda calculado 0.5323887 1 1 1024
#> 40 minimo calculado 0.4670620 1 1 3192
#> 41 maximo calculado 0.4670620 1 1 3192
#> 42 media calculado 0.4670620 1 1 3192
#> 43 n_ceros calculado 0.4670620 1 1 3192
#> 44 n_negativos calculado 0.4670620 1 1 3192
#> 45 mediana calculado 0.4010201 0 1 736
#> 46 desvio calculado 0.4670620 1 1 3192
#> 47 n_validos calculado 0.4670620 1 1 3192
#> 48 n_faltantes calculado 0.4670620 1 1 3192
#> 49 prop_faltantes calculado 0.4670620 1 1 3192
#> 50 n_distintos calculado 0.3950596 1 1 1216
#> 51 tasa_distintos calculado 0.3950596 1 1 1216
#> 52 moda calculado 0.4248619 0 1 1088
#> 53 frecuencia_moda calculado 0.4248619 0 1 1088
#> 54 minimo no_aplica NA NA NA NA
#> 55 maximo no_aplica NA NA NA NA
#> 56 media no_aplica NA NA NA NA
#> 57 n_ceros no_aplica NA NA NA NA
#> 58 n_negativos no_aplica NA NA NA NA
#> 59 mediana no_aplica NA NA NA NA
#> 60 desvio no_aplica NA NA NA NA
#> consulta_id id_consulta etapa nivel
#> 1 8 8 basicos 1
#> 2 8 8 basicos 2
#> 3 8 8 basicos 2
#> 4 8 8 basicos 2
#> 5 8 8 basicos 2
#> 6 8 8 basicos 2
#> 7 8 8 basicos 2
#> 8 9 9 conteos 1
#> 9 9 9 conteos 2
#> 10 11 NA moda 1
#> 11 11 NA moda 2
#> 12 8 8 basicos 2
#> 13 8 8 basicos 2
#> 14 8 8 basicos 2
#> 15 8 8 basicos 2
#> 16 8 8 basicos 2
#> 17 15 NA mediana 1
#> 18 8 8 basicos 2
#> 19 8 8 basicos 2
#> 20 8 8 basicos 2
#> 21 8 8 basicos 2
#> 22 9 9 conteos 2
#> 23 9 9 conteos 2
#> 24 12 NA moda 1
#> 25 12 NA moda 2
#> 26 NA NA resumen_sql 1
#> 27 NA NA resumen_sql 1
#> 28 NA NA resumen_sql 1
#> 29 NA NA resumen_sql 1
#> 30 NA NA resumen_sql 1
#> 31 NA NA resumen_sql 1
#> 32 NA NA resumen_sql 1
#> 33 8 8 basicos 2
#> 34 8 8 basicos 2
#> 35 8 8 basicos 2
#> 36 10 10 conteos 1
#> 37 10 10 conteos 2
#> 38 13 NA moda 1
#> 39 13 NA moda 2
#> 40 8 8 basicos 2
#> 41 8 8 basicos 2
#> 42 8 8 basicos 2
#> 43 8 8 basicos 2
#> 44 8 8 basicos 2
#> 45 16 NA mediana 1
#> 46 8 8 basicos 2
#> 47 8 8 basicos 2
#> 48 8 8 basicos 2
#> 49 8 8 basicos 2
#> 50 10 10 conteos 2
#> 51 10 10 conteos 2
#> 52 14 NA moda 1
#> 53 14 NA moda 2
#> 54 NA NA resumen_sql 1
#> 55 NA NA resumen_sql 1
#> 56 NA NA resumen_sql 1
#> 57 NA NA resumen_sql 1
#> 58 NA NA resumen_sql 1
#> 59 NA NA resumen_sql 1
#> 60 NA NA resumen_sql 1
perfil$resumen_tabla$tiempos
#> etapa duracion_ms cpu_ms estado nivel n_ejecuciones
#> 1 ausencia_estructural 0.667572 1 medido 2 1
#> 2 casi_duplicados_vocabulario 8.296728 14 medido 2 1
#> 3 dependencias 1.051664 1 medido 2 1
#> 4 duplicados_aproximados NA NA no_solicitado 2 1
#> 5 lectura_muestra 1.639128 1 medido 1 1
#> 6 perfilado_columnas 358.198881 359 medido 2 1
#> 7 perfilado_muestra 452.088594 463 medido 1 1resumen_tabla$tiempos reúne en milisegundos las etapas
grandes del lado R, incluidas la lectura y el perfilado de la muestra,
el perfilado por columna y los análisis opcionales. La columna
nivel dice cuáles se pueden sumar: las de
nivel = 1 son disjuntas entre sí y las de nivel mayor están
contenidas en alguna de ellas. perfilado_muestra es
inclusivo —contiene el perfilado por columna, las dependencias y los
casi-duplicados—, así que sumar la columna entera da más que la corrida
completa. instrumentar = FALSE conserva el mismo plan, la
misma cantidad y el mismo orden de consultas, pero deja las duraciones,
el CPU y los metadatos de tamaño en NA y marca las etapas
como no_medido.
resumen_tabla$sql$memoria_trabajo responde a la pregunta
«¿qué trabajo no debo recomputar incrementalmente sobre una tabla
mayor?». Sus tres valores son creciente,
acotado y NA cuando la fila no tiene
medición.
sql_memoria <- perfil$resumen_tabla$sql
filtro_memoria <- (
sql_memoria$memoria_trabajo %in% c("acotado", "creciente") |
is.na(sql_memoria$memoria_trabajo)
) & (
(sql_memoria$columna == "id" &
sql_memoria$metrica %in% c("n_validos", "n_distintos")) |
(sql_memoria$columna == "grupo" & sql_memoria$metrica == "minimo")
)
ejemplos_memoria <- sql_memoria[
filtro_memoria,
c("columna", "metrica", "estado", "alcance", "fraccion", "metodo",
"memoria_trabajo")
]
ejemplos_memoria
#> columna metrica estado alcance fraccion metodo memoria_trabajo
#> 5 id n_validos calculado tabla_completa 1 tabla_completa acotado
#> 8 id n_distintos calculado tabla_completa 1 COUNT(DISTINCT) creciente
#> 26 grupo minimo no_aplica tabla_completa 1 tabla_completa <NA>
stopifnot(
identical(
as.character(ejemplos_memoria$memoria_trabajo),
c("acotado", "creciente", NA_character_)
)
)La regla es: alcance efectivo con tope real, acotado; si
no, el método resuelto; sin medición, NA.
perfil_muestra: el perfil completo de la muestra
El perfil completo se obtiene en memoria sobre las cinco filas seleccionadas. La salida muestra algunos de sus 112 campos analíticos para que se vean, junto con los conteos, los faltantes, los distintos y el tipo inferido.
perfil$perfil_muestra$columnas[, c(
"columna", "n", "n_faltantes", "n_distintos", "tipo_inferido"
)]
#> columna n n_faltantes n_distintos tipo_inferido
#> 1 id 5 0 5 entero
#> 2 grupo 5 0 2 texto
#> 3 valor 5 1 4 doble
#> 4 fecha 5 0 5 fechaLos hallazgos de perfil_muestra conservan sus unidades
declaradas. En particular, las inconsistencias de mayúsculas, las
equivalencias Unicode y casi_duplicados_vocabulario cuentan
valores distintos, pero su trazabilidad enumera por fila todas las filas
que contienen esos valores. En el último caso incluye las formas
seleccionadas por el grupo, incluida la dominante. Por eso la traza
puede tener más filas que n_afectados; no es una
contradicción de alcance, sino el localizador del grupo que se debe
revisar. El orden de la traza prioriza las formas no dominantes antes de
las dominantes, para que max_filas_hallazgo conserve
primero las filas accionables; la evidencia declara cuántas filas
mostradas pertenecen a cada forma. En patron_raro, la
evidencia agrega la proporción del patrón dominante y las filas en
patrones no dominantes excluidos por superar
umbral_patron_raro; si el dominante no alcanza
umbral_patron_dominante, la no medición queda en
cobertura_diagnosticos.
Algunos controladores no saben traer ciertas columnas en una lectura
corriente —TEXT y NTEXT en SQL Server,
CLOB y BLOB en Oracle, bytea en
PostgreSQL— y responden con un error al pedirlas junto con el resto. Una
sola columna así se llevaba puesta la muestra entera. Ahora, cuando la
lectura falla, lupa averigua cuáles no se pueden
leer y reintenta sin ellas: la muestra vuelve con las columnas
que sí se pudieron leer, y resumen_tabla$cobertura gana una
fila alcance_distinto que nombra las que quedaron afuera y
conserva el motivo textual del motor.
Averiguarlo, y no deducirlo, es lo que hace que esto funcione contra
un controlador cualquiera. La versión anterior miraba el tipo que
declara el motor y lo comparaba contra una lista de nombres. Contra el
driver {SQL Server} eso no reconocía
ninguna columna, porque ese driver no informa nombres
sino códigos numéricos de ODBC —"-1" en vez de
"varchar(max)"—, y la muestra se perdía igual, en silencio.
Ahora el reintento le pregunta al motor: divide el
conjunto de columnas y va podando los subconjuntos que sí se leen, hasta
aislar las que fallan. El nombre del tipo sigue sirviendo de atajo —si
se reconoce, se ahorra el descarte—, pero ya no decide.
Eso cambia también lo que se puede afirmar. Cuando la omisión se
resolvió por descarte, omision_comprobada es
TRUE y el aviso lo dice: cada una de esas columnas falló
por sí sola y el resto se leyó junto. Cuando la resolvió el atajo, es
FALSE y el aviso mantiene el “no se comprobó que sean la
causa”, porque el reintento salta ante cualquier fallo y un corte de red
que se recupera en el segundo intento daría el mismo camino.
sondas_descarte publica cuántas consultas costó
averiguarlo. El descarte está acotado: como mucho dos sondas por
columna, y nunca más de la mitad del saldo de
max_consultas.
Si el descarte no aísla nada, no se inventa una culpable: la muestra se declara no disponible y el motivo dice cuántos subconjuntos se probaron y cómo terminó. En todos los casos el resumen por columna las cubre igual, porque los agregados se calculan en el motor; lo que falta es su perfil por fila. Para incluirlas, conviene convertirlas a texto acotado en una vista y perfilar la vista.
El bloque de metadatos del muestreo
—perfil_muestra$meta$origen_dbi$muestreo— describe lo que
de verdad se leyó y no lo que se había planeado leer:
columnas_leidas son las columnas que la muestra trae,
sql_muestra es la consulta que se emitió,
metodo y acotado_en describen la lectura que
salió —no la que se había planeado, que el reintento puede cambiar—, y
columnas_omitidas con motivo_columnas_omitidas
dicen cuáles quedaron afuera y qué respondió el motor.
El muestreo declarado
muestra = 5 declara las filas pedidas. La metadata
registra además las filas obtenidas, las doce filas totales, el método y
el orden. Sin orden_muestra, un LIMIT 5 no
tiene orden garantizado y reproducible es
FALSE.
Con orden_muestra = "id", lupa verifica en
la tabla completa que el orden sea único. Sólo entonces informa
reproducible = TRUE. Indicar una columna que se repite no
alcanza: el orden puede quedar declarado y aun así no determinar qué
filas ocupan el límite.
resumir_muestreo <- function(x, caso) {
m <- x$perfil_muestra$meta$origen_dbi$muestreo
data.frame(
caso = caso,
filas_pedidas = m$filas_solicitadas,
filas_obtenidas = m$filas_obtenidas,
filas_totales = m$filas_totales_fuente,
metodo = m$metodo,
orden = if (length(m$orden_muestra)) {
paste(m$orden_muestra, collapse = ", ")
} else {
"no declarado"
},
orden_unico_verificado = m$orden_unico_verificado,
reproducible = m$reproducible,
stringsAsFactors = FALSE
)
}
rbind(
resumir_muestreo(sin_orden, "sin orden"),
resumir_muestreo(perfil, "orden por id")
)
#> caso filas_pedidas filas_obtenidas filas_totales
#> 1 sin orden 5 5 12
#> 2 orden por id 5 5 12
#> metodo orden orden_unico_verificado reproducible
#> 1 primeras_filas_sin_orden_garantizado no declarado FALSE FALSE
#> 2 primeras_filas_segun_orden id TRUE TRUE
data.frame(
bloque = c("resumen_tabla", "perfil_muestra"),
solo_lectura = c(
perfil$resumen_tabla$meta$solo_lectura,
perfil$perfil_muestra$meta$origen_dbi$solo_lectura
),
objetos_temporales = c(
perfil$resumen_tabla$meta$objetos_temporales,
perfil$perfil_muestra$meta$origen_dbi$objetos_temporales
)
)
#> bloque solo_lectura objetos_temporales
#> 1 resumen_tabla TRUE FALSE
#> 2 perfil_muestra TRUE FALSE
invisible(DBI::dbDisconnect(conexion))La metadata no convierte una muestra en la tabla completa. Permite que quien consume el resultado compare cada bloque dentro de su propio alcance.
Muestras y aproximaciones para tablas grandes
La muestra usada por perfil_muestra y la relación usada
por universo = "muestra_motor" son declaradas por separado.
La primera se trae a R para ejecutar el perfil completo; la segunda
permanece en SQL para que los agregados no recorran la tabla completa.
La metadata de ambos bloques publica el universo y el método
efectivo.
estimado <- perfilar_dbi(
conexion, "entregas", universo = "muestra_motor", muestra_motor = 5000,
muestra = 5000, metricas = c(
"validos", "distintos", "moda", "basicos", "mediana", "desvio"
)
)
estimado$resumen_tabla$sql[
estimado$resumen_tabla$sql$estado %in% c("estimado", "observado_muestra"),
c("columna", "metrica", "estado", "universo", "tamano_muestra",
"metodo", "fraccion", "error_esperado")
]
aproximado <- perfilar_dbi(
conexion, "entregas", muestra = 5000,
estrategia_mediana = "aproximada_motor",
estrategia_distintos = "aproximada_motor"
)
aproximado$resumen_tabla$meta$aproximacionesLas estrategias de mediana y de distintos son independientes. Para
pedir una función nativa de cardinalidad hay que usar
estrategia_distintos = "aproximada_motor"; para habilitar
la aproximación de mediana se usa
estrategia_mediana = "aproximada_motor". La sonda de
mediana prueba primero una forma nativa exacta consolidada, luego la
forma exacta por columna y deja la aproximada para el final. Por eso una
mediana exacta queda calculado, aunque se haya pedido la
estrategia aproximada; sólo queda estimado cuando esa forma
aproximada efectivamente corrió. Una consulta no emitida o sin un valor
utilizable queda no_disponible.
plan_perfilado_dbi() emite las sondas de capacidad y
publica un rango de consultas en
attr(plan, "supuesto"). No escanea datos para decidir el
costo: lee el esquema, sondea capacidades y, con
politica_costo = "por_cardinalidad", puede usar una
garantía estructural o una fuente de catálogo. Nunca lanza
COUNT(DISTINCT ...) sólo para despejar una duda. Por eso el
plan no proyecta el costo temporal de COUNT(DISTINCT) antes
de correr: la referencia honesta es el primer lote de distintos medido
durante la ejecución, y el plan no emite consultas de datos.
La memoria del procesamiento no se estima
El plan declara explícitamente que la memoria del procesamiento no se estima: no escala de forma predecible con las filas ni con las celdas, según lo medido. Conserva la magnitud del trabajo que se conoce —filas, celdas y pares de texto—, rotulada como magnitud y no como consumo de memoria.
Las cifras que ayudan a decidir son datos de referencia medidos, no una predicción para la tabla del plan, sino una corrida única y fechada (2026-08-28) contra un motor remoto: traer la tabla costó aproximadamente 0,13 GB por millón de filas y procesar en R aproximadamente 1,0-1,5 MB por cada mil filas. La segunda cifra varió por 1,62x entre tablas de la misma magnitud; esa variación es justamente el motivo por el que no se usa para estimar.
Ver todas las filas y tener todas las filas en memoria no son lo mismo. En corridas de referencia, 4,5 millones de filas entraron en 0,6 GB y tardaron 25 segundos en llegar, mientras que procesar 4,5 millones ocupó aproximadamente 7 GB y procesar 12,8 millones aproximadamente 19 GB. El problema observado está en el procesamiento en R, no en la red ni en el motor.
El extremo inferior es total: si la fuente de
cardinalidad queda desconocida, supone que la política omite la moda. El
extremo superior es total_maximo —también reflejado en
total_lotes_rechazados tras sumar la bisección— y deja
abierto el camino que las ejecuta. Si un lote es rechazado, se agregan
hasta 2n - 1 sondas por lote de n columnas. El
costo real cae entre los dos, y el plan lo declara en las dos
direcciones.
estrategia_distintos dice cómo se obtiene o se omite
n_distintos, y publica la estrategia solicitada, la
resuelta y el estado antes y después de la corrida.
fuente_cardinalidad_costo dice de dónde sale la proporción
usada por la política de costo; es independiente y no puede convertir
una petición "aproximada_motor" o "catalogo"
en un agregado exacto.
Cada consulta de agregados planos que trae n_validos
incluye COUNT(*) AS n_total_consulta en la misma sentencia.
La completitud usa ese denominador local por lote, incluso después de
una bisección, y no cruza el total de otra consulta. El total del
universo se mantiene separado cuando hace falta para una muestra o
cuando no hay agregados planos. Después se ejecutan distintos, moda y
mediana. tamano_lote_planos y
tamano_lote_distintos son independientes; este último vale
2 por omisión. La medición contra el servidor de referencia mostró un
Shared Read constante entre lotes: dos cardinalidades se
amortizan en una pasada de la tabla. El derrame crece con el ancho del
lote; dos mantuvo casi el mismo tiempo por columna que una y derramó
menos que los lotes más anchos.
El costo de COUNT(DISTINCT ...) se anuncia después de
completar el primer lote de distintos y antes de iniciar el segundo,
cuando hay una referencia medida. Con instrumentar = TRUE,
la proyección usa la mediana de las duraciones de las consultas de ese
primer lote en esta misma corrida y la multiplica por la cantidad total
de lotes de distintos. El mensaje dice que es una estimación y de dónde
sale; no usa reltuples. Con un solo lote no hay nada que
proyectar ni que evitar, así que no se publica una proyección. La misma
regla funciona si se pide sólo metricas = "distintos". Sólo
se muestra cuando supera 30 segundos y no pide confirmación, por lo que
perfilar_dbi() no queda esperando en un guion.
Los avisos de moda y mediana tienen
interruptores y umbrales propios:
avisar_costo_moda/umbral_segundos_aviso_moda
proyectan por cardinalidad y
avisar_costo_mediana/umbral_segundos_aviso_mediana
proyectan por filas. Cada aviso llega antes de pagar la consulta y su
proyección queda separada en meta$costo_moda o
meta$costo_mediana; la fuente de cada cardinalidad se
publica, incluso cuando proviene de una estimación de catálogo.
plan_perfilado_dbi() no publica esa proyección temporal:
no emite consultas de datos y, por lo tanto, no puede medir el primer
lote. El plan lo declara explícitamente; la medición y la proyección
quedan en resumen_tabla$meta$costo_distintos después de
ejecutar.
En PostgreSQL, la preparación puede consultar
pg_stats.n_distinct, pg_stats.avg_width,
pg_class.reltuples, SHOW work_mem y, desde la
versión 13, SHOW hash_mem_multiplier. Con esos metadatos
estima el tamaño del hash de agregación y avisa antes del primer
COUNT(DISTINCT) si supera el límite efectivo.
meta$estimacion_derrame conserva ese diagnóstico como
estimación, no como medición; subir work_mem en la sesión
puede evitar el derrame y el paquete nunca modifica ese parámetro. Si
faltan estadísticas, permisos o el motor no es PostgreSQL, declara que
no pudo estimar.
El informe conserva el derrame real sólo cuando PostgreSQL permite
atribuirlo sin ambigüedad mediante pg_stat_statements:
resumen_tabla$sql publica los bloques temporales escritos y
meta$derrame resume las consultas observadas. Si esa
evidencia no está disponible, el estado lo dice y el paquete no deduce
un derrame del tiempo transcurrido ni cambia work_mem. La
medición real manda sobre meta$estimacion_derrame: un
derrame medido se informa aunque la estimación previa no hubiera
superado el límite.
La decisión de pagar moda y mediana es explícita.
politica_costo = "todas" es el valor por omisión y conserva
todas las métricas solicitadas. Con
politica_costo = "por_cardinalidad", la corrida resuelve
primero las fuentes estructurales y mide los valores válidos y distintos
sólo cuando no hay una fuente exacta y la estrategia lo permite; luego
omite, por columna, sólo la moda cuando
n_distintos / n_validos >= umbral_cardinalidad. El
umbral por omisión es 0.5 y gobierna sólo la moda. La
mediana se conserva: el barrido medido queda plano frente a la
cardinalidad y su costo lo gobierna el número de filas. Cada omisión
queda declarada en resumen_tabla$sql y
meta$decisiones_costo explica por separado cada decisión.
Una estrategia omitida, de catálogo o aproximada sin capacidad nunca se
convierte en COUNT(DISTINCT ...).
Ahora bien, cuántas consultas se emiten no dice cuánto cuestan: catorce consultas sobre dos millones de filas son mucho más trabajo que doscientas sobre mil, y quien mira el plan quiere saber si esto tarda segundos, minutos u horas. Por eso el plan estima además la magnitud, en cuentas de verdad y no en un índice inventado, y la estima en dos mitades, porque el reloj de una corrida no lo decide siempre el motor.
La del motor son filas_leidas, cuántas filas habría que
leer, y ordenaciones_completas, cuántas veces habría que
ordenar la tabla entera; se resume en magnitud_motor. La
del cliente son columnas_texto y pares_texto,
cuántos pares de formas podría comparar en R el detector de vocabulario
sobre la muestra; se resume en magnitud_texto.
magnitud es la mayor de las dos
—"baja", "media", "alta", o
"desconocida" si no se conoce el número de filas—.
Contar sólo el motor daba juicios falsos con números ciertos. Una
tabla del catálogo de PostGIS de 3.912 filas, con una columna de
geometría guardada como texto, pedía 64.592 lecturas de fila y cero
ordenaciones: magnitud "baja". Y tardaba 35 segundos,
ya con el presupuesto de trabajo calibrado —la misma
tabla tardaba 243 segundos antes, y esa medición es la que aparece en
vignette("escala-y-duplicados")—. El trabajo que quedaba
estaba del otro lado, comparando formas, que no es una lectura de fila.
Al imprimir el plan se ven las dos mitades, y el aviso de magnitud alta
viene con las palancas concretas para acotarla —que no son las mismas de
un lado que del otro—: universo = "muestra_motor", recortar
metricas, bajar muestra o poner
max_consultas para el motor, y
max_trabajo_vocabulario para lo que se hace en R.
Es una estimación y lo dice en
attr(plan, "supuesto_costo"). La del motor cuenta las filas
que habría que leer si ningún índice ayudara, y cada
ordenación completa como log2(filas) pasadas; un índice
sobre la columna ordenada, o una tabla que entra en la memoria del
motor, la bajan mucho. La del cliente cuenta pares: el conteo es exacto,
pero cuánto cuesta cada par depende del largo de los valores,
que el plan no conoce sin leerlos. Medido con
benchmark/medir_costo_texto.R, entre 660.000 y 1.150.000
pares por segundo con valores de cuarenta caracteres —la banda cubre dos
máquinas—, y entre 70.000 y 80.000 con valores de doscientos: con textos
muy largos el tiempo real es varias veces el que sugiere la referencia.
Los números publicados no dependen de esos supuestos, así que quien no
los comparta puede rehacer la cuenta.
Lo que el muestreo en el motor no puede darte
Los agregados planos que comparten lote —COUNT(col),
mínimo, máximo, media, ceros, negativos y desvío— se piden en una sola
consulta. COUNT(DISTINCT ...) y la moda conservan consultas
separadas por sus planes propios. La moda intenta traer
SUM(COUNT(*)) OVER () AS n_validos_guard junto a su
frecuencia y sondea esa forma antes de emitirla; si el motor la rechaza,
conserva la consulta anterior y lo declara en
resumen_tabla$meta$moda_guardian. La mediana también
conserva ese camino como respaldo; en PostgreSQL 9.3 y SQLite el conteo
de la mediana queda como subconsulta escalar en la misma sentencia que
ordena y recorta. Un motor que acepta
PERCENTILE_CONT(...) WITHIN GROUP puede consolidar varias
en un solo SELECT por lote.
Con universo = "muestra_motor", TABLESAMPLE
o la fuente pseudoaleatoria resuelta se ejecuta una sola vez y se
materializa en un spool externo de sesión cliente. El trailer conserva y
verifica muestra_id, snapshot_id,
orden_id, n_filas, bytes y
checksum; las pasadas de resumen y diagnóstico releen esa misma
relación. id_consulta sigue identificando la sentencia que
produjo una medición, mientras muestra_id identifica la
materialización. Un chunk que supera el presupuesto se rechaza antes de
escribir y publica spool_presupuesto_excedido y
muestra_inestable:presupuesto_materializacion; no se
entrega una muestra híbrida. El spool no escribe en la conexión DBI ni
crea objetos temporales del motor.
Por eso las formas candidatas están ordenadas por
previsibilidad del tamaño y no por costo. Primero la de
cantidad fija, TABLESAMPLE RESERVOIR (n ROWS); después la
de nivel de fila, TABLESAMPLE BERNOULLI (p); y recién al
final las de bloque. Medido contra PostgreSQL 16, pidiendo el 20 % de
una tabla de 5.000 filas en cuatro corridas:
TABLESAMPLE SYSTEM (20) 678 904 452 1384
TABLESAMPLE BERNOULLI (20) 1011 1017 981 1050
SYSTEM elige bloques enteros, así que el tamaño de la
muestra salta de un tercio al doble de lo pedido, y sobre una tabla
chica puede dar cero. Con tamaños tan distintos entre consultas, dos
métricas del mismo perfil dejan de ser comparables entre sí. La guarda
de coherencia interna compara la frecuencia de la moda con
n_validos_guard de su propia sentencia; si la sonda de la
ventana no pasa, deja la cota como no comprobable y conserva la medición
sin culpar al motor.
Si necesitás que todas las métricas describan exactamente las mismas
filas, el camino es perfil_muestra: trae una muestra a R
una sola vez y corre sobre ella el perfil completo.
Cuesta traer los datos; a cambio, todo lo que informa habla del mismo
conjunto.
