Estudio sobre la implementación de la lógica de negocio en funciones almacenadas de PostgreSQL

El motivo para escribir este estudio fue un artículo «Durante la cuarentena, la carga aumentó 5 veces, pero estábamos listos». Cómo Lingualeo migró a PostgreSQL con 23 millones de usuarios. También me pareció interesante un artículo publicado hace 4 años — Implementación de la lógica de negocio en MySQL.

Me pareció interesante que la misma idea-"implementar la lógica de negocio en la base de datos".

Estudio sobre la implementación de la lógica de negocio en funciones almacenadas de PostgreSQL

no se me ocurrió solo a mí.

Además, quería conservar, para mí principalmente, las ideas interesantes que surgieron durante la implementación. Especialmente teniendo en cuenta que recientemente se tomó la decisión estratégica de cambiar la arquitectura y trasladar la lógica de negocio al nivel del backend. Así que todo lo que se ha desarrollado pronto no será necesario para nadie y no interesará a nadie.

Los métodos descritos no son un descubrimiento ni algo excepcional know how, todo es clásico y ha sido implementado en múltiples ocasiones (yo, por ejemplo, apliqué un enfoque similar hace 20 años en Oracle). Simplemente decidí reunir todo en un solo lugar. Quizás le sirva a alguien. Como ha demostrado la práctica, a menudo la misma idea se le ocurre a diferentes personas de manera independiente. Y también, para dejar un recuerdo, es útil.

Por supuesto, nada en este mundo es perfecto, los errores y las erratas son posibles. Se agradecen y esperan críticas y comentarios. Y un pequeño detalle más: se han omitido detalles específicos de la implementación. Después de todo, todo se utiliza aún en un proyecto en funcionamiento. Así que el artículo es solo un estudio y una descripción del concepto general, no más que eso. Espero que para entender la imagen general, suficientes detalles.

La idea general es «divide y vencerás, oculta y poseerás»

La idea es clásica: un esquema separado para tablas, un esquema separado para funciones almacenadas.
El cliente no tiene acceso directo a los datos. Todo lo que el cliente puede hacer es llamar a una función almacenada y procesar la respuesta recibida.

Roles

CREATE ROLE store;

CREATE ROLE sys_functions;

CREATE ROLE loc_audit_functions;

CREATE ROLE service_functions;

CREATE ROLE business_functions;

Esquemas

Esquema de almacenamiento de tablas

Tablas objetivo que implementan entidades del dominio.

CREATE SCHEMA store AUTHORIZATION store;

Esquema de funciones del sistema

Funciones del sistema, en particular para registrar cambios en las tablas.

CREATE SCHEMA sys_functions AUTHORIZATION sys_functions;

Esquema de auditoría local

Funciones y tablas para implementar la auditoría local de la ejecución de funciones almacenadas y cambios en las tablas de destino.

CREATE SCHEMA loc_audit_functions AUTHORIZATION loc_audit_functions;

Esquema de funciones de servicio

Funciones para funciones de servicio y DML.

CREATE SCHEMA service_functions AUTHORIZATION service_functions;

Esquema de funciones comerciales

Funciones para las funciones comerciales finales llamadas por el cliente.

CREATE SCHEMA business_functions AUTHORIZATION business_functions;

Accesos

Rol — DBA tiene acceso completo a todos los esquemas (separado del rol de propietario de la base de datos).

CREATE ROLE dba_role;
GRANT store TO dba_role;
GRANT sys_functions TO dba_role;
GRANT loc_audit_functions TO dba_role;
GRANT service_functions TO dba_role;
GRANT business_functions TO dba_role;

Rol — USER tiene privilegio El resultado fue una solución bastante universal que se podía trasladar a todas las tablas: en el esquema business_functions.

CREATE ROLE user_role;

Privilegios entre esquemas

GRANT
Dado que todas las funciones se crean con el atributo SECURITY DEFINER se requiere la instrucción REVOKE EXECUTE ON ALL FUNCTION… FROM public;

REVOKE EXECUTE ON ALL FUNCTION IN SCHEMA sys_functions FROM public;
REVOKE EXECUTE ON ALL FUNCTION IN SCHEMA loc_audit_functions FROM public;
REVOKE EXECUTE ON ALL FUNCTION IN SCHEMA service_functions FROM public;
REVOKE EXECUTE ON ALL FUNCTION IN SCHEMA business_functions FROM public;

GRANT USAGE ON SCHEMA sys_functions TO dba_role;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA sys_functions TO dba_role;
GRANT USAGE ON SCHEMA loc_audit_functions TO dba_role;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA loc_audit_functions TO dba_role;
GRANT USAGE ON SCHEMA service_functions TO dba_role;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA service_functions TO dba_role;
GRANT USAGE ON SCHEMA business_functions TO dba_role;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA business_functions TO dba_role;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA business_functions TO user_role;

GRANT ALL PRIVILEGES ON SCHEMA store TO GROUP business_functions;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA store TO business_functions;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA store TO business_functions;

Así que el esquema de la base de datos está listo. Se puede proceder a llenar los datos.

Tablas de destino

La creación de tablas es trivial. Sin características especiales, excepto que se decidió prescindir del uso de SERIAL y generar secuencias explícitamente. Además, por supuesto, el máximo uso de la instrucción

COMMENT ON ...

Comentarios para todas objetos, sin excepciones.

Auditoría local

Para llevar un registro de la ejecución de funciones almacenadas y cambios en las tablas de destino, se utiliza una tabla de auditoría local que incluye detalles de la conexión del cliente, la etiqueta del módulo llamado, y los valores reales de los parámetros de entrada y salida en formato JSON.

Funciones del sistema

Destinadas a registrar cambios en las tablas de destino. Son funciones de disparador.

Plantilla — función del sistema

---------------------------------------------------------
-- INSERTAR
CREATE OR REPLACE FUNCTION sys_functions.table_insert_log ()
RETURNS TRIGGER AS $$
BEGIN
  PERFORM loc_audit_functions.make_log( ' '||'tabla' , 'insertar' , json_build_object('id', NEW.id)  );
  RETURN NULL ;
END
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER table_after_insert AFTER INSERT ON storage.table FOR EACH ROW EXECUTE PROCEDURE sys_functions.table_insert_log();

---------------------------------------------------------
-- ACTUALIZAR
CREATE OR REPLACE FUNCTION sys_functions.table_update_log ()
RETURNS TRIGGER AS $$
BEGIN
  IF OLD.column != NEW.column
  THEN
    PERFORM loc_audit_functions.make_log( ' '||'tabla' , 'actualizar' , json_build_object('OLD.column', OLD.column , 'NEW.column' , NEW.column )  );
  END IF ;
  RETURN NULL ;
END
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER table_after_update AFTER UPDATE ON storage.table FOR EACH ROW EXECUTE PROCEDURE sys_functions.table_update_log ();

---------------------------------------------------------
-- ELIMINAR
CREATE OR REPLACE FUNCTION sys_functions.table_delete_log ()
RETURNS TRIGGER AS $$
BEGIN
  PERFORM loc_audit_functions.make_log( ' '||'tabla' , 'eliminar' , json_build_object('id', OLD.id )  );
  RETURN NULL ;
END
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER table_after_delete AFTER DELETE ON storage.table FOR EACH ROW EXECUTE PROCEDURE sys_functions.table_delete_log ();

Funciones de servicio

Destinadas a la implementación de operaciones de servicio y DML en las tablas objetivo.

Plantilla — función de servicio

--INSERTAR
--DEVOLVER id DE LA NUEVA FILA
CREATE OR REPLACE FUNCTION service_functions.table_insert ( new_column store.table.column%TYPE )
RETURNS integer AS $$
DECLARE
  new_id integer ;
BEGIN
  -- Generar nuevo id
  new_id = nextval('store.table.seq');

  -- Insertar en la tabla
  INSERT INTO store.table
  ( 
    id ,
    column
   )
  VALUES
  (
   new_id ,
   new_column
   );

RETURN new_id ;
END
$$ LANGUAGE plpgsql SECURITY DEFINER;

--ELIMINAR
--DEVOLVER NÚMEROS DE FILAS ELIMINADAS
CREATE OR REPLACE FUNCTION service_functions.table_delete ( current_id integer ) 
RETURNS integer AS $$
DECLARE
  rows_count integer  ;    
BEGIN
  DELETE FROM store.table WHERE id = current_id; 

  GET DIAGNOSTICS rows_count = ROW_COUNT;                                                                           

  RETURN rows_count ;
END
$$ LANGUAGE plpgsql SECURITY DEFINER;
 
-- ACTUALIZAR DETALLES
-- DEVOLVER NÚMEROS DE FILAS ACTUALIZADAS
CREATE OR REPLACE FUNCTION service_functions.table_update_column 
(
  current_id integer 
  ,new_column store.table.column%TYPE
) 
RETURNS integer AS $$
DECLARE
  rows_count integer  ; 
BEGIN
  UPDATE  store.table
  SET
    column = new_column
  WHERE id = current_id;

  GET DIAGNOSTICS rows_count = ROW_COUNT;                                                                           

  RETURN rows_count ;
END
$$ LANGUAGE plpgsql SECURITY DEFINER;

Funciones de negocio

Destinadas a las funciones de negocio finales invocadas por el cliente. Siempre devuelven — JSON. Para interceptar y registrar errores de ejecución, se utiliza un bloque EXCEPCIÓN.

Plantilla — función de negocio

CREAR O REEMPLAZAR FUNCIÓN business_functions.business_function_template(
-- Parámetros de entrada        
 )
RETORNA JSON AS $$
DECLARE
  ------------------------
  -- para captura de excepciones
  error_message texto ;
  error_json json ;
  resultado json ;
  ------------------------ 
BEGIN
--REGISTRO
  PERFORM loc_audit_functions.make_log
  (
    'business_function_template',
    'INICIADO',
    json_build_object
    (
	-- Parámetros de entrada
    ) 
   );

  PERFORM business_functions.notice('business_function_template');            

  -- INICIO DE LA PARTE DEL NEGOCIO
  -- FIN DE LA PARTE DEL NEGOCIO

  -- RESULTADO EXITOSO
  PERFORM business_functions.notice('resultado');
  PERFORM business_functions.notice(resultado);

  PERFORM loc_audit_functions.make_log
  (
    'business_function_template',
    'FINALIZADO', 
    json_build_object( 'resultado',resultado )
  );

  RETURN resultado ;
----------------------------------------------------------------------------------------------------------
-- CAPTURA DE EXCEPCIONES
EXCEPCIÓN                        
  CUANDO OTROS ENTONCES    
    PERFORM loc_audit_functions.make_log
    (
      'business_function_template',
      'INICIADO',
      json_build_object
      (
	-- Parámetros de entrada	
      ) , VERDADERO );

     PERFORM loc_audit_functions.make_log
     (
       'business_function_template',
       ' ERROR',
       json_build_object('SQLSTATE',SQLSTATE ), VERDADERO 
     );

     PERFORM loc_audit_functions.make_log
     (
       'business_function_template',
       ' ERROR',
       json_build_object('SQLERRM',SQLERRM  ), VERDADERO 
      );

     GET STACKED DIAGNOSTICS error_message = RETURNED_SQLSTATE ;
     PERFORM loc_audit_functions.make_log
     (
      'business_function_template',
      ' ERROR-RETURNED_SQLSTATE',json_build_object('RETURNED_SQLSTATE',error_message  ), VERDADERO );

     GET STACKED DIAGNOSTICS error_message = COLUMN_NAME ;
     PERFORM loc_audit_functions.make_log
     (
       'business_function_template',
       ' ERROR-COLUMN_NAME',
       json_build_object('COLUMN_NAME',error_message  ), VERDADERO );

     GET STACKED DIAGNOSTICS error_message = CONSTRAINT_NAME ;
     PERFORM loc_audit_functions.make_log
     (
      'business_function_template',
      ' ERROR-CONSTRAINT_NAME',
      json_build_object('CONSTRAINT_NAME',error_message  ), VERDADERO );

     GET STACKED DIAGNOSTICS error_message = PG_DATATYPE_NAME ;
     PERFORM loc_audit_functions.make_log
     (
       'business_function_template',
       ' ERROR-PG_DATATYPE_NAME',
       json_build_object('PG_DATATYPE_NAME',error_message  ), VERDADERO );

     GET STACKED DIAGNOSTICS error_message = MESSAGE_TEXT ;
     PERFORM loc_audit_functions.make_log
     (
       'business_function_template',
       ' ERROR-MESSAGE_TEXT',json_build_object('MESSAGE_TEXT',error_message  ), VERDADERO );

     GET STACKED DIAGNOSTICS error_message = SCHEMA_NAME ;
     PERFORM loc_audit_functions.make_log
     (
       'business_function_template',
       ' ERROR-SCHEMA_NAME',json_build_object('SCHEMA_NAME',error_message  ), VERDADERO );

     GET STACKED DIAGNOSTICS error_message = PG_EXCEPTION_DETAIL ;
     PERFORM loc_audit_functions.make_log
     (
      'business_function_template',
      ' ERROR-PG_EXCEPTION_DETAIL',
      json_build_object('PG_EXCEPTION_DETAIL',error_message  ), VERDADERO );

     GET STACKED DIAGNOSTICS error_message = PG_EXCEPTION_HINT ;
     PERFORM loc_audit_functions.make_log
     (
       'business_function_template',
       ' ERROR-PG_EXCEPTION_HINT',json_build_object('PG_EXCEPTION_HINT',error_message  ), VERDADERO );

     GET STACKED DIAGNOSTICS error_message = PG_EXCEPTION_CONTEXT ;
     PERFORM loc_audit_functions.make_log
     (
      'business_function_template',
      ' ERROR-PG_EXCEPTION_CONTEXT',json_build_object('PG_EXCEPTION_CONTEXT',error_message  ), VERDADERO );                                      

    RAISE WARNING 'ALARMA: %' , SQLERRM ;

    SELECT json_build_object
    (
      'isError' , VERDADERO ,
      'errorMsg' , SQLERRM
     ) INTO error_json ;

  RETURN  error_json ;
END
$$ LENGUAJE plpgsql DEFINidor DE SEGURIDAD;

Summary

Para describir el panorama general, creo que es más que suficiente. Si alguien está interesado en los detalles y los resultados, escriba comentarios, con gusto añadiré toques adicionales al cuadro.

P.D.

Registro de un error simple: tipo de parámetro de entrada

-[ REGISTRO 1 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1072
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          | INICIADO
jsonb_pretty    | {
                |     "dko": {
                |         "id": 4,
                |         "type": "Tipo1",
                |         "title": "CREADO POR addKD",
                |         "Weight": 10,
                |         "Tr": "300",
                |         "reduction": 10,
                |         "isTrud": "VERDADERO",
                |         "description": "descripción",
                |         "lowerTr": "100",
                |         "measurement": "medida1",
                |         "methodology": "m1",
                |         "passportUrl": "files",
                |         "upperTr": "200",
                |         "weightingFactor": 100.123,
                |         "actualTrValue": null,
                |         "upperTrCalcNumber": "120"
                |     },
                |     "CardId": 3
                | }
-[ REGISTRO 2 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1073
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR
jsonb_pretty    | {
                |     "SQLSTATE": "22P02"
                | }
-[ REGISTRO 3 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1074
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR
jsonb_pretty    | {
                |     "SQLERRM": "sintaxis de entrada inválida para tipo numérico: "null""
                | }
-[ REGISTRO 4 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1075
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR-RETURNED_SQLSTATE
jsonb_pretty    | {
                |     "RETURNED_SQLSTATE": "22P02"
                | }
-[ REGISTRO 5 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1076
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR-COLUMN_NAME
jsonb_pretty    | {
                |     "COLUMN_NAME": ""
                | }

-[ REGISTRO 6 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1077
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR-CONSTRAINT_NAME
jsonb_pretty    | {
                |     "CONSTRAINT_NAME": ""
                | }
-[ REGISTRO 7 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1078
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR-PG_DATATYPE_NAME
jsonb_pretty    | {
                |     "PG_DATATYPE_NAME": ""
                | }
-[ REGISTRO 8 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1079
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR-MESSAGE_TEXT
jsonb_pretty    | {
                |     "MESSAGE_TEXT": "sintaxis de entrada inválida para tipo numérico: "null""
                | }
-[ REGISTRO 9 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1080
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR-SCHEMA_NAME
jsonb_pretty    | {
                |     "SCHEMA_NAME": ""
                | }
-[ REGISTRO 10 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1081
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR-PG_EXCEPTION_DETAIL
jsonb_pretty    | {
                |     "PG_EXCEPTION_DETAIL": ""
                | }
-[ REGISTRO 11 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1082
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR-PG_EXCEPTION_HINT
jsonb_pretty    | {
                |     "PG_EXCEPTION_HINT": ""
                | }
-[ REGISTRO 12 ]-
date_trunc      | 2020-08-19 13:15:46
id              | 1083
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR-PG_EXCEPTION_CONTEXT
jsonb_pretty    | {
usename         | emp1
log_module      | addKD
log_module_hash | 0b4c1529a89af3ddf6af3821dc790e8a
status          |  ERROR-MESSAGE_TEXT
jsonb_pretty    | {
                |     "MESSAGE_TEXT": "sintaxis de entrada inválida para tipo numérico: "null""
                | }

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster