Developing UDFs with PL/pgSQL

1. Feature Overview

PL/pgSQL is a built-in loadable procedural programming language for databases. It's designed specifically for SQL, adding core procedural programming features such as variable declaration, control flow, and error handling on top of SQL.

Important Limitation Note: The current version does not support any form of database table access operations (including but not limited to SELECT, INSERT, UPDATE, DELETE, SELECT...INTO, etc.). PL/pgSQL is currently only available for pure computational logic processing, control flow, and parameter operations.

Core Value:

  • Logic Encapsulation: Encapsulate complex computational logic into reusable code units.
  • Control Flow: Provide conditional statements, loops, and other structured programming capabilities.
  • Data Transformation: Calculate and transform input parameters before returning results.
  • Code Modularity: Improve code maintainability and reusability.

Note: Using PL/pgSQL requires first binding a UDF Server and keeping the UDF Server in a running state.

2. Core Features and Syntax Essentials

2.1 Variable Declaration (DECLARE)

DECLARE
    -- Basic types
    counter INTEGER := 0;
    total_amount NUMERIC(10, 2);
    user_name VARCHAR(50) := 'guest';
    is_valid BOOLEAN DEFAULT false;
    current_time TIMESTAMP := NOW();
 
    -- Array types
    scores INTEGER[] := ARRAY[90, 85, 78, 92];
    names TEXT[] := ARRAY['Alice', 'Bob', 'Charlie'];
 
    -- Record types
    user_record RECORD;
    complex_data JSONB;

2.2 Rich Control Flow Examples

Complex Conditional Statements

-- Multi-condition composite judgment
IF age >= 18 AND has_id = true AND (membership_level > 1 OR is_vip = true) THEN
    access_granted := true;
    welcome_message := 'Distinguished member, welcome!';
ELSIF age >= 18 AND has_id = true THEN
    access_granted := true;
    welcome_message := 'Welcome!';
ELSE
    access_granted := false;
    welcome_message := 'Sorry, you do not meet entry requirements';
END IF;

Array Loop Processing

-- Traverse array and process
DECLARE
    numbers INTEGER[] := ARRAY[1, 2, 3, 4, 5];
    squared_numbers INTEGER[];
    total INTEGER := 0;
BEGIN
    FOR i IN 1..array_length(numbers, 1) LOOP
        squared_numbers[i] := numbers[i] * numbers[i];
        total := total + numbers[i];
    END LOOP;
 
    -- Calculate average
    avg_value := total / array_length(numbers, 1);
END;

Nested Loop Example

-- Generate multiplication table
FOR i IN 1..9 LOOP
    FOR j IN 1..9 LOOP
        result := i * j;
        -- Can store to array or process directly
    END LOOP;
END LOOP;

2.3 Advanced Computation and Data Processing

Mathematical Calculation Function

CREATE OR REPLACE FUNCTION calculate_compound_interest(
    principal NUMERIC,
    rate NUMERIC,
    years INTEGER
) RETURNS NUMERIC AS $$
DECLARE
    amount NUMERIC;
BEGIN
    amount := principal * POWER(1 + rate/100, years);
    RETURN ROUND(amount, 2);
END;
$$ LANGUAGE plpgsql;

String Processing Function

CREATE OR REPLACE FUNCTION format_phone_number(raw_phone TEXT)
RETURNS TEXT AS $$
BEGIN
    -- Remove all non-digit characters
    raw_phone := REGEXP_REPLACE(raw_phone, '[^0-9]', '', 'g');
 
    -- Format to standard phone number format
    IF LENGTH(raw_phone) = 11 THEN
        RETURN SUBSTRING(raw_phone FROM 1 FOR 3) || '-' ||
               SUBSTRING(raw_phone FROM 4 FOR 4) || '-' ||
               SUBSTRING(raw_phone FROM 8 FOR 4);
    ELSE
        RETURN raw_phone;
    END IF;
END;
$$ LANGUAGE plpgsql;

Date-Time Processing

CREATE OR REPLACE FUNCTION get_business_days(
    start_date DATE,
    end_date DATE
) RETURNS INTEGER AS $$
DECLARE
    curr_date DATE;
    business_days INTEGER := 0;
BEGIN
    curr_date := start_date;
 
    WHILE curr_date <= end_date LOOP
        -- Exclude weekends (0=Sunday, 6=Saturday)
        IF EXTRACT(DOW FROM curr_date) NOT IN (0, 6) THEN
            business_days := business_days + 1;
        END IF;
        curr_date := curr_date + 1;
    END LOOP;
 
    RETURN business_days;
END;
$$ LANGUAGE plpgsql;

2.4 Complex Data Validation

Multi-Rule Data Validation

CREATE OR REPLACE FUNCTION validate_user_input(
    username TEXT,
    email TEXT,
    age INTEGER
) RETURNS JSONB AS $$
DECLARE
    validation_result JSONB := '{"valid": true, "errors": []}';
    error_message TEXT;
BEGIN
    -- Username validation
    IF LENGTH(username) < 3 THEN
        error_message := 'Username must be at least 3 characters';
        validation_result := jsonb_set(validation_result, '{valid}', 'false');
        validation_result := jsonb_set(validation_result, '{errors}',
            (validation_result->'errors') || to_jsonb(error_message));
    END IF;
 
    -- Email validation
    IF email !~ '^[A-Za-z0-9._%-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$' THEN
        error_message := 'Invalid email format';
        validation_result := jsonb_set(validation_result, '{valid}', 'false');
        validation_result := jsonb_set(validation_result, '{errors}',
            (validation_result->'errors') || to_jsonb(error_message));
    END IF;
 
    -- Age validation
    IF age < 18 OR age > 120 THEN
        error_message := 'Age must be between 18-120 years';
        validation_result := jsonb_set(validation_result, '{valid}', 'false');
        validation_result := jsonb_set(validation_result, '{errors}',
            (validation_result->'errors') || to_jsonb(error_message));
    END IF;
 
    RETURN validation_result;
END;
$$ LANGUAGE plpgsql;

3. Practical Example Collection

Example 1: Password Strength Validation

CREATE OR REPLACE FUNCTION check_password_strength(password TEXT)
RETURNS TEXT AS $$
DECLARE
    strength_score INTEGER := 0;
BEGIN
    -- Length check
    IF LENGTH(password) >= 8 THEN strength_score := strength_score + 1; END IF;
    IF LENGTH(password) >= 12 THEN strength_score := strength_score + 1; END IF;
 
    -- Complexity check
    IF password ~ '[A-Z]' THEN strength_score := strength_score + 1; END IF; -- Uppercase
    IF password ~ '[a-z]' THEN strength_score := strength_score + 1; END IF; -- Lowercase
    IF password ~ '[0-9]' THEN strength_score := strength_score + 1; END IF; -- Numbers
    IF password ~ '[^A-Za-z0-9]' THEN strength_score := strength_score + 1; END IF; -- Special chars
 
    -- Return strength level
    RETURN CASE strength_score
        WHEN 0 THEN 'Very Weak'
        WHEN 1 THEN 'Weak'
        WHEN 2 THEN 'Fair'
        WHEN 3 THEN 'Good'
        ELSE 'Very Strong'
    END;
END;
$$ LANGUAGE plpgsql;

Example 2: Data Encryption Function

CREATE OR REPLACE FUNCTION simple_encrypt(
    input_text TEXT,
    shift_key INTEGER
) RETURNS TEXT AS $$
DECLARE
    encrypted_text TEXT := '';
    i INTEGER;
    char_code INTEGER;
BEGIN
    FOR i IN 1..LENGTH(input_text) LOOP
        char_code := ASCII(SUBSTRING(input_text FROM i FOR 1));
        -- Simple Caesar cipher encryption
        encrypted_text := encrypted_text || CHR((char_code + shift_key) % 256);
    END LOOP;
 
    RETURN encrypted_text;
END;
$$ LANGUAGE plpgsql;

Example 3: Configuration Parser

CREATE OR REPLACE FUNCTION parse_config(config_string TEXT)
RETURNS JSONB AS $$
DECLARE
    config_json JSONB := '{}';
    key_value_pair TEXT[];
    lines TEXT[];
    line TEXT;
BEGIN
    -- Split configuration string by lines
    lines := STRING_TO_ARRAY(config_string, E'\n');
 
    FOREACH line IN ARRAY lines LOOP
        -- Parse key-value pairs (ignore comments and empty lines)
        IF line != '' AND line NOT LIKE '#%' AND POSITION('=' IN line) > 0 THEN
            key_value_pair := STRING_TO_ARRAY(line, '=');
            config_json := jsonb_set(config_json, ARRAY[TRIM(key_value_pair[1])],
                to_jsonb(TRIM(key_value_pair[2])));
        END IF;
    END LOOP;
 
    RETURN config_json;
END;
$$ LANGUAGE plpgsql;

Example 4: Mathematical Statistics Function

CREATE OR REPLACE FUNCTION calculate_statistics(numbers NUMERIC[])
RETURNS JSONB AS $$
DECLARE
    stats JSONB;
    total NUMERIC := 0;
    min_val NUMERIC;
    max_val NUMERIC;
    i INTEGER;
BEGIN
    IF array_length(numbers, 1) IS NULL THEN
        RETURN '{"count": 0, "error": "Empty array"}'::JSONB;
    END IF;
 
    -- Initialize
    min_val := numbers[1];
    max_val := numbers[1];
 
    -- Calculate statistics
    FOR i IN 1..array_length(numbers, 1) LOOP
        total := total + numbers[i];
        IF numbers[i] < min_val THEN min_val := numbers[i]; END IF;
        IF numbers[i] > max_val THEN max_val := numbers[i]; END IF;
    END LOOP;
 
    stats := jsonb_build_object(
        'count', array_length(numbers, 1),
        'sum', total,
        'average', total / array_length(numbers, 1),
        'min', min_val,
        'max', max_val,
        'range', max_val - min_val
    );
 
    RETURN stats;
END;
$$ LANGUAGE plpgsql;

Advanced Examples

Financial Calculations

CREATE OR REPLACE FUNCTION calculate_loan_payment(
    principal NUMERIC,
    annual_rate NUMERIC,
    years INTEGER
) RETURNS JSONB AS $$
DECLARE
    monthly_rate NUMERIC;
    num_payments INTEGER;
    monthly_payment NUMERIC;
    total_payment NUMERIC;
    total_interest NUMERIC;
BEGIN
    -- Input validation
    IF principal <= 0 OR annual_rate < 0 OR years <= 0 THEN
        RETURN '{"error": "Invalid input parameters"}'::JSONB;
    END IF;
 
    monthly_rate := annual_rate / 100 / 12;
    num_payments := years * 12;
 
    -- Calculate monthly payment using loan formula
    IF monthly_rate = 0 THEN
        monthly_payment := principal / num_payments;
    ELSE
        monthly_payment := principal * (monthly_rate * POWER(1 + monthly_rate, num_payments)) /
                          (POWER(1 + monthly_rate, num_payments) - 1);
    END IF;
 
    total_payment := monthly_payment * num_payments;
    total_interest := total_payment - principal;
 
    RETURN jsonb_build_object(
        'monthly_payment', ROUND(monthly_payment, 2),
        'total_payment', ROUND(total_payment, 2),
        'total_interest', ROUND(total_interest, 2),
        'effective_rate', ROUND(annual_rate, 4)
    );
END;
$$ LANGUAGE plpgsql;
 
-- Usage
SELECT calculate_loan_payment(200000, 4.5, 30);

Text Analysis Function

CREATE OR REPLACE FUNCTION analyze_text(input_text TEXT)
RETURNS JSONB AS $$
DECLARE
    word_count INTEGER;
    char_count INTEGER;
    sentence_count INTEGER;
    paragraph_count INTEGER;
    words TEXT[];
    avg_word_length NUMERIC;
BEGIN
    IF input_text IS NULL OR LENGTH(TRIM(input_text)) = 0 THEN
        RETURN '{"error": "Empty text provided"}'::JSONB;
    END IF;
 
    -- Basic counts
    char_count := LENGTH(input_text);
    
    -- Word analysis
    words := STRING_TO_ARRAY(REGEXP_REPLACE(input_text, '[^\w\s]', '', 'g'), ' ');
    words := ARRAY(SELECT unnest(words) WHERE unnest IS NOT NULL AND unnest != '');
    word_count := array_length(words, 1);
    
    -- Calculate average word length
    IF word_count > 0 THEN
        avg_word_length := (SELECT AVG(LENGTH(word)) FROM unnest(words) AS word);
    ELSE
        avg_word_length := 0;
    END IF;
 
    -- Sentence count (approximate)
    sentence_count := LENGTH(input_text) - LENGTH(REPLACE(REPLACE(REPLACE(input_text, '.', ''), '!', ''), '?', ''));
    
    -- Paragraph count (approximate)
    paragraph_count := LENGTH(input_text) - LENGTH(REPLACE(input_text, E'\n\n', '')) + 1;
 
    RETURN jsonb_build_object(
        'character_count', char_count,
        'word_count', COALESCE(word_count, 0),
        'sentence_count', sentence_count,
        'paragraph_count', paragraph_count,
        'average_word_length', ROUND(COALESCE(avg_word_length, 0), 2),
        'reading_time_minutes', ROUND(COALESCE(word_count, 0) / 200.0, 1) -- Assuming 200 words per minute
    );
END;
$$ LANGUAGE plpgsql;
 
-- Usage
SELECT analyze_text('This is a sample text for analysis. It contains multiple sentences! How does it perform?');

Data Structure Operations

CREATE OR REPLACE FUNCTION process_json_array(json_data JSONB, operation TEXT DEFAULT 'sum')
RETURNS JSONB AS $$
DECLARE
    element JSONB;
    result NUMERIC := 0;
    count INTEGER := 0;
    min_val NUMERIC;
    max_val NUMERIC;
    current_val NUMERIC;
BEGIN
    -- Validate input
    IF json_data IS NULL OR jsonb_typeof(json_data) != 'array' THEN
        RETURN '{"error": "Invalid JSON array provided"}'::JSONB;
    END IF;
 
    -- Process each element
    FOR element IN SELECT jsonb_array_elements(json_data) LOOP
        IF jsonb_typeof(element) = 'number' THEN
            current_val := element::TEXT::NUMERIC;
            count := count + 1;
            
            CASE operation
                WHEN 'sum' THEN
                    result := result + current_val;
                WHEN 'product' THEN
                    IF count = 1 THEN result := current_val;
                    ELSE result := result * current_val;
                    END IF;
                ELSE
                    result := result + current_val; -- Default to sum
            END CASE;
            
            -- Track min/max
            IF count = 1 THEN
                min_val := current_val;
                max_val := current_val;
            ELSE
                IF current_val < min_val THEN min_val := current_val; END IF;
                IF current_val > max_val THEN max_val := current_val; END IF;
            END IF;
        END IF;
    END LOOP;
 
    IF count = 0 THEN
        RETURN '{"error": "No numeric values found in array"}'::JSONB;
    END IF;
 
    RETURN jsonb_build_object(
        'operation', operation,
        'result', result,
        'count', count,
        'average', ROUND(result / count, 4),
        'min', min_val,
        'max', max_val
    );
END;
$$ LANGUAGE plpgsql;
 
-- Usage
SELECT process_json_array('[1, 2, 3, 4, 5]'::JSONB, 'sum');
SELECT process_json_array('[2, 3, 4]'::JSONB, 'product');

Utility Functions

CREATE OR REPLACE FUNCTION generate_random_string(
    length INTEGER DEFAULT 10,
    charset TEXT DEFAULT 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
) RETURNS TEXT AS $$
DECLARE
    result TEXT := '';
    i INTEGER;
    random_index INTEGER;
BEGIN
    IF length <= 0 THEN
        RETURN '';
    END IF;
 
    FOR i IN 1..length LOOP
        random_index := FLOOR(RANDOM() * LENGTH(charset) + 1);
        result := result || SUBSTRING(charset FROM random_index FOR 1);
    END LOOP;
 
    RETURN result;
END;
$$ LANGUAGE plpgsql;
 
-- Usage
SELECT generate_random_string(8);
SELECT generate_random_string(12, '0123456789');

Best Practices

1. Error Handling

CREATE OR REPLACE FUNCTION safe_division(numerator NUMERIC, denominator NUMERIC)
RETURNS NUMERIC AS $$
BEGIN
    IF denominator = 0 THEN
        RAISE EXCEPTION 'Division by zero is not allowed';
    END IF;
    
    RETURN numerator / denominator;
EXCEPTION
    WHEN OTHERS THEN
        RAISE NOTICE 'Error in safe_division: %', SQLERRM;
        RETURN NULL;
END;
$$ LANGUAGE plpgsql;

2. Input Validation

CREATE OR REPLACE FUNCTION validate_and_process(input_value TEXT)
RETURNS TEXT AS $$
BEGIN
    -- Comprehensive input validation
    IF input_value IS NULL THEN
        RAISE EXCEPTION 'Input cannot be NULL';
    END IF;
    
    IF LENGTH(TRIM(input_value)) = 0 THEN
        RAISE EXCEPTION 'Input cannot be empty';
    END IF;
    
    IF LENGTH(input_value) > 1000 THEN
        RAISE EXCEPTION 'Input too long (max 1000 characters)';
    END IF;
    
    -- Process the validated input
    RETURN UPPER(TRIM(input_value));
END;
$$ LANGUAGE plpgsql;

3. Performance Optimization

-- Use appropriate return types
CREATE OR REPLACE FUNCTION calculate_percentage(part NUMERIC, whole NUMERIC)
RETURNS NUMERIC AS $$
BEGIN
    IF whole = 0 THEN
        RETURN 0;
    END IF;
    
    RETURN ROUND((part / whole) * 100, 2);
END;
$$ LANGUAGE plpgsql IMMUTABLE; -- Mark as IMMUTABLE for better performance

This comprehensive guide provides everything needed to develop sophisticated PL/pgSQL UDFs in TacNode, from basic control structures to advanced computational logic and best practices.