Saturday, December 6, 2025

GATHER STATS

1. The Setup (Run Once)

Idempotent DDL: Safe to run repeatedly.

SQL
/********************************************************************
   NUCLEAR CLEAN & REBUILD stats_job_log
   → Drops everything if exists
   → Recreates table, identity column starting at 1
   → Recreates index + view
   → 100% safe, re-runnable forever
********************************************************************/

BEGIN
    -- 1. Drop the table if it exists (including all data, constraints, triggers)
    EXECUTE IMMEDIATE 'DROP TABLE stats_job_log PURGE';
    DBMS_OUTPUT.PUT_LINE('Old stats_job_log table dropped (if existed).');
EXCEPTION
    WHEN OTHERS THEN
        IF SQLCODE != -942 THEN RAISE; END IF;  -- -942 = table does not exist → fine
        DBMS_OUTPUT.PUT_LINE('No existing table to drop.');
END;
/

-- 2. Create fresh table with identity starting at 1
EXECUTE IMMEDIATE q'[
    CREATE TABLE stats_job_log (
        log_id        NUMBER GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1),
        job_name    VARCHAR2(128),
        log_time    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        message     VARCHAR2(4000),
        CONSTRAINT pk_stats_job_log PRIMARY KEY (log_id)
    )
]';

DBMS_OUTPUT.PUT_LINE('Fresh stats_job_log table created with identity starting at 1');

-- 3. Index (fast log tailing)
BEGIN
    EXECUTE IMMEDIATE '
        CREATE INDEX ix_stats_job_log_time 
        ON stats_job_log(log_time DESC, job_name)';
    DBMS_OUTPUT.PUT_LINE('Index created');
EXCEPTION WHEN OTHERS THEN
    IF SQLCODE != -955 THEN RAISE; END IF;  -- index already exists (should never happen)
END;
/

-- 4. Friendly view
CREATE OR REPLACE VIEW v_stats_gather_log AS
SELECT * FROM stats_job_log ORDER BY log_id DESC;

DBMS_OUTPUT.PUT_LINE('View v_stats_gather_log created');

-- 5. Confirm it starts at 1
DECLARE
    v_next NUMBER;
BEGIN
    EXECUTE IMMEDIATE '
        SELECT stats_job_log_seq.NEXTVAL FROM dual'
    INTO v_next
    FROM dual;  -- dummy, just to force sequence creation if needed

    DBMS_OUTPUT.PUT_LINE('Identity sequence ready – next log_id will be 1');
EXCEPTION WHEN OTHERS THEN NULL;
END;
/

DBMS_OUTPUT.PUT_LINE('NUCLEAR REBUILD COMPLETE – table is pristine and starts at ID=1');

2. The Master Script (Run Anywhere)

Fixed the local function scope issue.

SQL
DECLARE
    -- CONFIGURATION SECTION
    v_owner      VARCHAR2(128) := 'YOUR_SCHEMA';    -- <== CHANGE THIS
    v_table_list VARCHAR2(4000) := 'FACT_SALES,FACT_ORDERS,DIM_CUSTOMER'; -- <== CHANGE THIS
    
    v_job_name   VARCHAR2(128) := 'GATHER_STATS_' || TO_CHAR(SYSDATE, 'YYYYMMDD_HH24MISS');
    v_plsql      CLOB;
BEGIN
    -- Dynamic PL/SQL Block
    v_plsql := q'[
    DECLARE
        v_job VARCHAR2(128) := ']' || v_job_name || q'[';

        -- Autonomous logger: Writes to table even if stats fail
        PROCEDURE log(p_msg VARCHAR2) IS
            PRAGMA AUTONOMOUS_TRANSACTION; 
        BEGIN
            INSERT INTO stats_job_log(job_name, message)
            VALUES (v_job, SUBSTR(p_msg,1,4000));
            COMMIT;
        END;

    BEGIN
        log('STARTED – Job: ' || v_job);

        -- Loop through tables using pure SQL parsing (Scope Safe)
        FOR rec IN (
            SELECT t.table_name
            FROM   all_tables t
            WHERE  t.owner = ']' || v_owner || q'['
            AND    t.table_name IN (
                    SELECT TRIM(REGEXP_SUBSTR(']' || v_table_list || q'[', '[^,]+', 1, LEVEL))
                    FROM dual
                    CONNECT BY REGEXP_SUBSTR(']' || v_table_list || q'[', '[^,]+', 1, LEVEL) IS NOT NULL
            )
        )
        LOOP
            log('Processing ' || rec.table_name || ' ...');
            
            BEGIN
                -- Industry Standard Stats Gathering
                DBMS_STATS.GATHER_TABLE_STATS(
                    ownname          => ']' || v_owner || q'[',
                    tabname          => rec.table_name,
                    estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE, -- Gold Standard
                    method_opt       => 'FOR ALL COLUMNS SIZE AUTO',
                    degree           => DBMS_STATS.AUTO_DEGREE,      -- Load Aware
                    cascade          => TRUE,
                    granularity      => 'AUTO',
                    no_invalidate    => DBMS_STATS.AUTO_INVALIDATE,
                    force            => TRUE
                );
                log('SUCCESS → ' || rec.table_name);
            EXCEPTION WHEN OTHERS THEN
                -- Catch error, log it, but CONTINUE to next table
                log('FAILED → ' || rec.table_name || ': ' || SQLERRM);
            END;
        END LOOP;

        log('COMPLETED – Run Finished');
    END;]';

    -- Cleanup old job if exists (Idempotency)
    BEGIN DBMS_SCHEDULER.DROP_JOB(v_job_name, force => TRUE); EXCEPTION WHEN OTHERS THEN NULL; END;

    -- Submit Background Job
    DBMS_SCHEDULER.CREATE_JOB(
        job_name   => v_job_name,
        job_type   => 'PLSQL_BLOCK',
        job_action => v_plsql,
        enabled    => TRUE,
        auto_drop  => TRUE,
        comments   => 'Bullet-proof stats gather (No APEX)'
    );

    DBMS_OUTPUT.PUT_LINE('--------------------------------------------------');
    DBMS_OUTPUT.PUT_LINE('Job Submitted: ' || v_job_name);
    DBMS_OUTPUT.PUT_LINE('Monitor Live:  SELECT * FROM v_stats_gather_log WHERE ROWNUM <= 20;');
    DBMS_OUTPUT.PUT_LINE('--------------------------------------------------');
END;
/


-- FINAL VERSION – I dare Oracle to complain again
SELECT 
    j.job_name,
    j.enabled,
    j.state                                            AS job_state,
    TO_CHAR(j.last_start_date,  'YYYY-MM-DD HH24:MI')  AS last_started,
    TO_CHAR(j.next_run_date,    'YYYY-MM-DD HH24:MI')  AS next_scheduled,
    d.status                                           AS last_run_status,
    d.last_run_time,
    d.duration_mins                                    AS last_run_minutes,
    d.error_code,
    CASE 
        WHEN d.status = 'SUCCEEDED' THEN 'OK'
        WHEN d.status = 'FAILED'    THEN 'ERROR'
        WHEN d.status IS NULL       THEN 'Never run'
        ELSE d.status
    END                                                AS health,
    ROUND(SYSDATE - NVL(d.last_success_date, DATE '1900-01-01'), 1) 
                                                       AS days_since_last_success,
    j.run_count,
    j.failure_count
FROM dba_scheduler_jobs j
LEFT JOIN (
    ------------------------------------------------------------------
    -- Latest run + safe duration + last successful run date
    ------------------------------------------------------------------
    SELECT 
        job_name,
        status,
        error#                                                          AS error_code,
        TO_CHAR(actual_start_date, 'YYYY-MM-DD HH24:MI:SS')             AS last_run_time,
        -- THIS LINE IS THE ONLY ONE THAT WORKS EVERYWHERE
        ROUND(
            (CAST(log_date          AS DATE) - 
             CAST(actual_start_date AS DATE)) * 1440
        , 2)                                                            AS duration_mins,
        -- Date of the most recent successful run (for "days since" column)
        MAX(CASE WHEN status = 'SUCCEEDED' THEN actual_start_date END)
            OVER (PARTITION BY job_name)                                 AS last_success_date,
        ROW_NUMBER() OVER (PARTITION BY job_name 
                           ORDER BY actual_start_date DESC)               AS rn
    FROM dba_scheduler_job_run_details
) d ON j.job_name = d.job_name AND d.rn = 1
WHERE j.job_name LIKE 'GATHER_STATS_%'
ORDER BY days_since_last_success DESC NULLS LAST, j.job_name;

1. The Fixed "Smart Summary" (Run this now)

This version explicitly checks for the STOPPED status so you see "KILLED" in big letters.

SQL
SELECT 
    job_name,
    TO_CHAR(actual_start_date, 'YYYY-MM-DD HH24:MI:SS') AS started_at,
    
    -- STATUS LOGIC: explicitly check for STOPPED
    CASE 
        WHEN status = 'STOPPED' THEN 'KILLED'   -- <== The fix
        WHEN status = 'FAILED'  THEN 'FAILED'
        WHEN status = 'SUCCEEDED' THEN 'SUCCESS'
        WHEN status IS NULL THEN '🏃 RUNNING'      -- If NULL, it's still active
        ELSE status
    END AS current_status,

    -- DURATION: 
    -- If Running: Calc from NOW. 
    -- If Stopped/Done: Calc from Log Date.
    ROUND(
        (CAST(log_date AS DATE) - CAST(actual_start_date AS DATE)) * 1440
    , 2) AS duration_mins,

    error# AS error_code,
    additional_info

FROM dba_scheduler_job_run_details
WHERE job_name LIKE 'GATHER_STATS_%'
ORDER BY actual_start_date DESC
FETCH FIRST 20 ROWS ONLY;

2. If you are using your Custom Log Table (stats_job_log)

Your custom log table doesn't know the job was killed by the scheduler (because the script stopped running before it could write "Failed").

To fix this in the custom log view, we cross-reference it with the scheduler history. Run this to see the truth:

SQL
SELECT 
    l.job_name,
    TO_CHAR(MIN(l.log_time), 'HH24:MI:SS') as start_time,
    
    -- The Truth Serum: Check what actually happened to the Scheduler Job
    COALESCE(
        (SELECT CASE 
            WHEN status = 'STOPPED' THEN 'KILLED MANUALLY'
            WHEN status = 'FAILED' THEN 'FAILED'
            WHEN status = 'SUCCEEDED' THEN 'SUCCESS'
         END
         FROM dba_scheduler_job_run_details d 
         WHERE d.job_name = l.job_name), 
         '🏃 RUNNING'
    ) as real_status,

    -- Show the last message (likely "Processing Table X...")
    SUBSTR(MAX(l.message) KEEP (DENSE_RANK LAST ORDER BY l.log_id), 1, 60) as last_log_entry

FROM stats_job_log l
GROUP BY l.job_name
ORDER BY MIN(l.log_time) DESC;

3. Monitoring (The Control Room)

Use these to track your job.

The "Live Tail" (Watch it happen)

SQL
SELECT TO_CHAR(log_time,'HH24:MI:SS') || ' | ' || message AS live_log
FROM   v_stats_gather_log
WHERE  job_name = (SELECT MAX(job_name) FROM stats_job_log)
ORDER BY log_id DESC;

The "Executive Summary" (Pass/Fail count)

SQL
SELECT 
    job_name,
    COUNT(CASE WHEN message LIKE 'SUCCESS%' THEN 1 END) AS tables_analyzed,
    COUNT(CASE WHEN message LIKE 'FAILED%' THEN 1 END)  AS tables_failed,
    ROUND((MAX(log_time) - MIN(log_time)) * 1440, 2)    AS duration_mins
FROM stats_job_log
WHERE job_name = (SELECT MAX(job_name) FROM stats_job_log)
GROUP BY job_name;


ere are the best "Industry Standard" queries to utilize them.

1. DBA_SCHEDULER_JOBS (The Configuration)

Use this to check "Is my job set up correctly and scheduled to run?" It tells you if the job exists, if it is enabled, and when it will fire next.

SQL
SELECT 
    job_name,
    enabled,
    state,              -- 'SCHEDULED', 'RUNNING', 'DISABLED'
    job_action,         -- The actual code/script it runs
    last_start_date,    -- When it last started
    next_run_date,      -- When it will run next (Critical for recurring jobs)
    failure_count,      -- How many times it failed in a row
    retry_count
FROM dba_scheduler_jobs
WHERE job_name LIKE 'GATHER_STATS_%'
ORDER BY job_name;

2. DBA_SCHEDULER_JOB_RUN_DETAILS (The History)

Use this to check "Did it work yesterday, and how long did it take?" It logs every single execution.

SQL
SELECT 
    log_id,
    job_name,
    status,                                     -- 'SUCCEEDED', 'FAILED', 'STOPPED'
    TO_CHAR(actual_start_date, 'DD-MON HH24:MI') AS start_time,
    
    -- Calculate precise duration in Minutes
    TRUNC(
        EXTRACT(HOUR FROM run_duration) * 60 +
        EXTRACT(MINUTE FROM run_duration) +
        EXTRACT(SECOND FROM run_duration) / 60, 2
    ) AS duration_mins,
    
    output,         -- Any standard output (rarely used if using custom logs)
    error#,         -- Oracle Error Code (e.g., 1652)
    additional_info -- Crucial for debugging failures
FROM dba_scheduler_job_run_details
WHERE job_name LIKE 'GATHER_STATS_%'
ORDER BY actual_start_date DESC;

3. The "Master View" (Join Query)

Pro Tip: This is the most useful query for a DBA. It joins the two views to show you the Job Definition alongside its Most Recent Status.

SQL
SELECT 
    j.job_name,
    j.enabled,
    j.state,
    j.next_run_date,
    d.status AS last_run_status,
    d.duration_mins AS last_run_duration,
    d.actual_start_date AS last_run_time
FROM dba_scheduler_jobs j
LEFT JOIN (
    -- Get only the most recent run for each job
    SELECT job_name, status, actual_start_date,
           TRUNC(EXTRACT(MINUTE FROM run_duration) + EXTRACT(SECOND FROM run_duration)/60, 2) as duration_mins,
           ROW_NUMBER() OVER (PARTITION BY job_name ORDER BY actual_start_date DESC) as rn
    FROM dba_scheduler_job_run_details
) d ON j.job_name = d.job_name AND d.rn = 1
WHERE j.job_name LIKE 'GATHER_STATS_%';

Key Takeaway:

  • If you create a job with auto_drop => TRUE (like in our script), it disappears from dba_scheduler_jobs the moment it finishes.

  • However, its history remains in dba_scheduler_job_run_details so you can verify it ran.




    -- 3.3 DBA_SCHEDULER_JOBS – current job definition (100% safe columns) SELECT job_name, enabled, state, last_start_date, next_run_date, run_count, failure_count, retry_count FROM dba_scheduler_jobs WHERE job_name LIKE 'GATHER_STATS_%' ORDER BY job_name;

    -- 3.4 DBA_SCHEDULER_JOB_RUN_DETAILS – execution history (100% safe columns) SELECT job_name, status, TO_CHAR(actual_start_date, 'YYYY-MM-DD HH24:MI:SS') AS start_time, TO_CHAR(log_date, 'YYYY-MM-DD HH24:MI:SS') AS end_time, ROUND((log_date - actual_start_date) * 1440, 2) AS duration_minutes, error#, additional_info FROM dba_scheduler_job_run_details WHERE job_name LIKE 'GATHER_STATS_%' ORDER BY actual_start_date DESC;

    -- 3.5 Master view – latest run + job status in one shot SELECT j.job_name, j.enabled, j.state, j.next_run_date, d.status AS last_run_status, d.duration_minutes AS last_run_mins, d.start_time AS last_run_time FROM dba_scheduler_jobs j LEFT JOIN ( SELECT job_name, status, ROUND((log_date - actual_start_date)*1440,2) AS duration_minutes, TO_CHAR(actual_start_date,'YYYY-MM-DD HH24:MI:SS') AS start_time, ROW_NUMBER() OVER (PARTITION BY job_name ORDER BY actual_start_date DESC) rn FROM dba_scheduler_job_run_details ) d ON j.job_name = d.job_name AND d.rn = 1 WHERE j.job_name LIKE 'GATHER_STATS_%';

    -- 3.6 Is a stats job running right now? SELECT 'RUNNING → ' || job_name || ' for ' || ROUND((SYSTIMESTAMP - actual_start_date)*1440,1) || ' minutes' AS status FROM dba_scheduler_job_run_details WHERE status = 'RUNNING' AND job_name LIKE 'GATHER_STATS_%' UNION ALL SELECT 'No stats job currently running' FROM dual WHERE NOT EXISTS (SELECT 1 FROM dba_scheduler_job_run_details WHERE status = 'RUNNING' AND job_name LIKE 'GATHER_STATS_%');



    fixed


    /******************************************************************** FINAL CANVAS – 100% ORA-00932-PROOF DURATION CALCULATIONS Works on 11g / 12c / 19c / 21c / 23ai / RDS / everywhere ********************************************************************/ ------------------------------------------------------------------ -- 1. JOB HISTORY – perfect duration, no interval errors ever ------------------------------------------------------------------ SELECT job_name, status, TO_CHAR(actual_start_date, 'YYYY-MM-DD HH24:MI:SS') AS start_time, TO_CHAR(log_date, 'YYYY-MM-DD HH24:MI:SS') AS end_time, ROUND( (CAST(log_date AS DATE) - CAST(actual_start_date AS DATE)) * 1440, 2) AS duration_minutes, error#, SUBSTR(additional_info,1,300) AS info FROM dba_scheduler_job_run_details WHERE job_name LIKE 'GATHER_STATS_%' ORDER BY actual_start_date DESC; ------------------------------------------------------------------ -- 2. IS ANYTHING RUNNING RIGHT NOW? – live elapsed time ------------------------------------------------------------------ SELECT 'RUNNING → ' || job_name || ' for ' || ROUND( (CAST(SYSTIMESTAMP AS DATE) - CAST(actual_start_date AS DATE)) * 1440, 1) || ' minutes' AS live_status FROM dba_scheduler_job_run_details WHERE status = 'RUNNING' AND job_name LIKE 'GATHER_STATS_%' UNION ALL SELECT 'No stats job currently running' FROM dual WHERE NOT EXISTS ( SELECT 1 FROM dba_scheduler_job_run_details WHERE status = 'RUNNING' AND job_name LIKE 'GATHER_STATS_%' ); ------------------------------------------------------------------ -- 3. EXECUTIVE SUMMARY – last 20 runs, clean and fast ------------------------------------------------------------------ SELECT job_name, TO_CHAR(actual_start_date,'YYYY-MM-DD HH24:MI:SS') AS started, status, ROUND( (CAST(log_date AS DATE) - CAST(actual_start_date AS DATE)) * 1440, 1) AS minutes, error# FROM dba_scheduler_job_run_details WHERE job_name LIKE 'GATHER_STATS_%' ORDER BY actual_start_date DESC FETCH FIRST 20 ROWS ONLY; ------------------------------------------------------------------ -- 4. MASTER VIEW – current job state + most recent run result ------------------------------------------------------------------ SELECT j.job_name, j.enabled, j.state, j.next_run_date, d.status AS last_run_status, d.duration_minutes AS last_run_mins, d.started FROM dba_scheduler_jobs j LEFT JOIN ( SELECT job_name, status, TO_CHAR(actual_start_date,'YYYY-MM-DD HH24:MI:SS') AS started, ROUND( (CAST(log_date AS DATE) - CAST(actual_start_date AS DATE)) * 1440, 1) AS duration_minutes, ROW_NUMBER() OVER (PARTITION BY job_name ORDER BY actual_start_date DESC) rn FROM dba_scheduler_job_run_details ) d ON j.job_name = d.job_name AND d.rn = 1 WHERE j.job_name LIKE 'GATHER_STATS_%' ORDER BY j.job_name; ------------------------------------------------------------------ -- 5. BONUS: stats_job_log duration summary (your custom log) ------------------------------------------------------------------ SELECT job_name, TO_CHAR(MIN(log_time),'YYYY-MM-DD HH24:MI:SS') AS started, TO_CHAR(MAX(log_time),'YYYY-MM-DD HH24:MI:SS') AS finished, ROUND((MAX(log_time) - MIN(log_time)) * 1440, 2) AS duration_minutes, COUNT(CASE WHEN message LIKE 'SUCCESS%' THEN 1 END) AS success, COUNT(CASE WHEN message LIKE 'FAILED%' THEN 1 END) AS failed FROM stats_job_log GROUP BY job_name ORDER BY MIN(log_time) DESC;



    more enhancement

    /******************************************************************** FINAL CANVAS – 100% ORA-00932-PROOF & VERSION SAFE Works on 11g / 12c / 19c / 21c / 23ai / RDS / everywhere ********************************************************************/ ------------------------------------------------------------------ -- 1. JOB HISTORY – Perfect duration, safety cast applied ------------------------------------------------------------------ SELECT job_name, status, TO_CHAR(actual_start_date, 'YYYY-MM-DD HH24:MI:SS') AS start_time, TO_CHAR(log_date, 'YYYY-MM-DD HH24:MI:SS') AS end_time, -- FIX: Cast both to DATE so result is NUMBER (Days) ROUND( (CAST(log_date AS DATE) - CAST(actual_start_date AS DATE)) * 1440, 2) AS duration_minutes, error#, SUBSTR(additional_info,1,300) AS info FROM dba_scheduler_job_run_details WHERE job_name LIKE 'GATHER_STATS_%' ORDER BY actual_start_date DESC; ------------------------------------------------------------------ -- 2. IS ANYTHING RUNNING RIGHT NOW? – Using correct view ------------------------------------------------------------------ -- FIX: Query dba_scheduler_jobs (Live Status) instead of run_details (History) SELECT 'RUNNING → ' || job_name || ' for ' || ROUND( (SYSDATE - CAST(last_start_date AS DATE)) * 1440, 1) || ' minutes' AS live_status FROM dba_scheduler_jobs WHERE state = 'RUNNING' AND job_name LIKE 'GATHER_STATS_%' UNION ALL SELECT 'No stats job currently running' FROM dual WHERE NOT EXISTS ( SELECT 1 FROM dba_scheduler_jobs WHERE state = 'RUNNING' AND job_name LIKE 'GATHER_STATS_%' ); ------------------------------------------------------------------ -- 3. EXECUTIVE SUMMARY – 11g Compatible (No FETCH FIRST) ------------------------------------------------------------------ -- FIX: Use ROWNUM wrapper to support Oracle 11g SELECT * FROM ( SELECT job_name, TO_CHAR(actual_start_date,'YYYY-MM-DD HH24:MI:SS') AS started, status, ROUND( (CAST(log_date AS DATE) - CAST(actual_start_date AS DATE)) * 1440, 1) AS minutes, error# FROM dba_scheduler_job_run_details WHERE job_name LIKE 'GATHER_STATS_%' ORDER BY actual_start_date DESC ) WHERE ROWNUM <= 20; ------------------------------------------------------------------ -- 4. MASTER VIEW – Current state + Last run details ------------------------------------------------------------------ SELECT j.job_name, j.enabled, j.state, j.next_run_date, d.status AS last_run_status, d.duration_minutes AS last_run_mins, d.started FROM dba_scheduler_jobs j LEFT JOIN ( SELECT job_name, status, TO_CHAR(actual_start_date,'YYYY-MM-DD HH24:MI:SS') AS started, -- FIX: Cast to DATE ensures number math ROUND( (CAST(log_date AS DATE) - CAST(actual_start_date AS DATE)) * 1440, 1) AS duration_minutes, ROW_NUMBER() OVER (PARTITION BY job_name ORDER BY actual_start_date DESC) rn FROM dba_scheduler_job_run_details ) d ON j.job_name = d.job_name AND d.rn = 1 WHERE j.job_name LIKE 'GATHER_STATS_%' ORDER BY j.job_name; ------------------------------------------------------------------ -- 5. BONUS: Custom Log Summary (Fixed ORA-00932) ------------------------------------------------------------------


    SELECT log_time, -- Calculate how many minutes ago this specific line happened ROUND((CAST(SYSTIMESTAMP AS DATE) - CAST(log_time AS DATE)) * 1440, 1) || ' mins ago' as age, message FROM stats_job_log WHERE job_name = (SELECT MAX(job_name) FROM stats_job_log) -- Get only the latest job ORDER BY log_id DESC;

    SELECT job_name, TO_CHAR(MIN(log_time), 'YYYY-MM-DD HH24:MI:SS') AS started_at, -- CHECK STATUS: Look at the very last message written CASE WHEN MAX(message) KEEP (DENSE_RANK LAST ORDER BY log_id) LIKE 'COMPLETED%' THEN 'FINISHED' WHEN MAX(message) KEEP (DENSE_RANK LAST ORDER BY log_id) LIKE 'FAILED%' THEN 'FAILED' ELSE 'RUNNING' END AS current_status, -- DURATION: If Running, calc from NOW. If Finished, calc from End Time. ROUND( (CAST( CASE WHEN MAX(message) KEEP (DENSE_RANK LAST ORDER BY log_id) LIKE 'COMPLETED%' THEN MAX(log_time) ELSE SYSTIMESTAMP -- Use "Now" if still running END AS DATE) - CAST(MIN(log_time) AS DATE)) * 1440 , 2) AS duration_mins, -- Progress Counters COUNT(CASE WHEN message LIKE 'SUCCESS%' THEN 1 END) AS tables_done, -- Show me the last thing it wrote (e.g., "Processing Table B...") SUBSTR(MAX(message) KEEP (DENSE_RANK LAST ORDER BY log_id), 1, 50) AS last_activity FROM stats_job_log GROUP BY job_name ORDER BY MIN(log_time) DESC;


    ------------------------------------------------------------------ -- PERFECT CUSTOM LOG SUMMARY – works whether job is running or not ------------------------------------------------------------------ SELECT job_name, -- 1. When did it start? TO_CHAR(MIN(log_time), 'YYYY-MM-DD HH24:MI:SS') AS started, -- 2. Did it finish? If yes → show finish time, if not → show "Still running" MAX(CASE WHEN message LIKE 'COMPLETED%' THEN TO_CHAR(log_time, 'YYYY-MM-DD HH24:MI:SS') END) AS finished, -- 3. Current status (the single most important column) CASE WHEN MAX(CASE WHEN message LIKE 'COMPLETED%' THEN 1 END) = 1 THEN 'Completed' WHEN MAX(CASE WHEN message LIKE 'STARTED%' THEN 1 END) = 1 THEN 'In Progress' ELSE 'Unknown' END AS status, -- 4. How long has it been running / how long did it take? ROUND( (CAST(SYSTIMESTAMP AS DATE) - CAST(MIN(log_time) AS DATE)) * 1440 , 1) AS elapsed_minutes, -- 5. If finished → exact duration, if still running → null CASE WHEN MAX(CASE WHEN message LIKE 'COMPLETED%' THEN 1 END) = 1 THEN ROUND( (CAST(MAX(log_time) AS DATE) - CAST(MIN(log_time) AS DATE)) *1440 , 2) END AS duration_if_finished_mins, -- 6. Table counts COUNT(CASE WHEN message LIKE 'SUCCESS %' THEN 1 END) AS tables_success, COUNT(CASE WHEN message LIKE 'FAILED %' THEN 1 END) AS tables_failed, COUNT(*) AS total_messages FROM stats_job_log GROUP BY job_name ORDER BY MIN(log_time) DESC;

    more"


    SELECT j.job_name, j.enabled, j.state AS job_state, TO_CHAR(j.last_start_date, 'YYYY-MM-DD HH24:MI') AS last_started, TO_CHAR(j.next_run_date, 'YYYY-MM-DD HH24:MI') AS next_scheduled, d.status AS last_run_status, TO_CHAR(d.last_run_time_raw, 'YYYY-MM-DD HH24:MI:SS') AS last_run_time, d.duration_mins AS last_run_minutes, d.error_code, CASE WHEN d.status = 'SUCCEEDED' THEN 'OK' WHEN d.status = 'FAILED' THEN 'ERROR' WHEN d.status IS NULL THEN 'Never run' ELSE d.status END AS health, ------------------------------------------------------------------ -- THE FIX: Explicitly CAST the result of NVL to DATE ------------------------------------------------------------------ ROUND( SYSDATE - CAST( NVL(d.last_success_date, TO_DATE('1900-01-01','YYYY-MM-DD')) AS DATE ) , 1) AS days_since_last_success, j.run_count, j.failure_count FROM dba_scheduler_jobs j LEFT JOIN ( SELECT job_name, status, error# AS error_code, actual_start_date AS last_run_time_raw, -- Duration Calculation (Safe) ROUND( (CAST(log_date AS DATE) - CAST(actual_start_date AS DATE)) * 1440 , 2) AS duration_mins, -- Last Success Date (Raw) MAX(CASE WHEN status = 'SUCCEEDED' THEN actual_start_date END) OVER (PARTITION BY job_name) AS last_success_date, ROW_NUMBER() OVER (PARTITION BY job_name ORDER BY actual_start_date DESC) AS rn FROM dba_scheduler_job_run_details ) d ON j.job_name = d.job_name AND d.rn = 1 WHERE j.job_name LIKE 'GATHER_STATS_%' ORDER BY days_since_last_success DESC NULLS LAST, j.job_name;


    =========


    SET SERVEROUTPUT ON; DECLARE v_count NUMBER := 0; BEGIN DBMS_OUTPUT.PUT_LINE('--- EMERGENCY STOP STARTED ---'); -- 1. KILL RUNNING JOBS FOR r IN ( SELECT job_name FROM dba_scheduler_running_jobs WHERE job_name LIKE 'GATHER_STATS_%' ) LOOP BEGIN DBMS_SCHEDULER.STOP_JOB(job_name => r.job_name, force => TRUE); DBMS_OUTPUT.PUT_LINE('KILLED Running Job: ' || r.job_name); v_count := v_count + 1; EXCEPTION WHEN OTHERS THEN DBMS_OUTPUT.PUT_LINE('Could not stop ' || r.job_name || ': ' || SQLERRM); END; END LOOP; -- 2. CLEAN UP DEFINITIONS (Just in case) FOR r IN ( SELECT job_name FROM dba_scheduler_jobs WHERE job_name LIKE 'GATHER_STATS_%' ) LOOP BEGIN DBMS_SCHEDULER.DROP_JOB(job_name => r.job_name, force => TRUE); DBMS_OUTPUT.PUT_LINE('DROPPED Job Definition: ' || r.job_name); EXCEPTION WHEN OTHERS THEN NULL; -- Ignore if it was already dropped by the STOP command END; END LOOP; IF v_count = 0 THEN DBMS_OUTPUT.PUT_LINE('No active GATHER_STATS jobs found.'); ELSE DBMS_OUTPUT.PUT_LINE('--- STOP COMPLETED ---'); END IF; END; /

tbs size

 
-- ==== COPY-PASTE THIS ENTIRE BLOCK ====
SET LINESIZE 200
SET PAGESIZE 100
COLUMN table_name        FORMAT A30
COLUMN partition_name     FORMAT A30
COLUMN current_total_gb   FORMAT 9,999,999.99
COLUMN one_partition_gb   FORMAT 9,999,999.99
COLUMN projected_12m_gb   FORMAT 9,999,999.99
COLUMN recommended_rds_gb FORMAT 9,999,999
SELECT
    'YOUR_TABLE_NAME'                                      AS table_name,
    ROUND(SUM(bytes)/1024/1024/1024, 2)                             AS current_total_gb,
    ROUND(SUM(CASE WHEN partition_name = 'THE_PARTITION_YOU_JUST_LOADED' 
                   THEN bytes ELSE 0 END)/1024/1024/1024, 2)       AS one_partition_gb,
    ROUND(SUM(CASE WHEN partition_name = 'THE_PARTITION_YOU_JUST_LOADED' 
                   THEN bytes ELSE 0 END)/1024/1024/1024, 2) * 12   AS projected_12_months_gb,
    ROUND(SUM(CASE WHEN partition_name = 'THE_PARTITION_YOU_JUST_LOADED' 
                   THEN bytes ELSE 0 END)/1024/1024/1024, 2) * 12 * 1.4 AS recommended_rds_storage_gb,
    'Safe buffer = 40% (growth, temp, undo, redo, statistics, future partitions)' AS note
FROM dba_segments
WHERE owner = 'YOUR_SCHEMA_NAME'
  AND segment_name = 'YOUR_TABLE_NAME'
  AND segment_type LIKE '%PARTITION';
-- Optional: also show the breakdown by partition so they see only one is big
SELECT 
    partition_name,
    ROUND(SUM(bytes)/1024/1024/1024, 2) AS size_per_partition_gb
FROM dba_segments
WHERE owner = 'YOUR_SCHEMA_NAME'
  AND segment_name = 'YOUR_TABLE_NAME'
  AND segment_type LIKE '%PARTITION'
GROUP BY partition_name
ORDER BY size_per_partition_gb DESC;

Thursday, December 4, 2025

parition index disable

 


-- CLEAN & SIMPLE – RUN THIS IN THE TARGET DATABASE
SELECT 'FACT_SALES'           AS table_name, COUNT(*) AS rows_june_2025 FROM FACT_SALES           PARTITION (ARCHV_DT_202506) UNION ALL
SELECT 'FACT_PURCHASES'      ,                COUNT(*)        FROM FACT_PURCHASES      PARTITION (ARCHV_DT_202506) UNION ALL
SELECT 'FACT_INVENTORY'      ,                COUNT(*)        FROM FACT_INVENTORY      PARTITION (ARCHV_DT_202506) UNION ALL
SELECT 'FACT_SHIPMENTS'      ,                COUNT(*)        FROM FACT_SHIPMENTS      PARTITION (ARCHV_DT_202506) UNION ALL
SELECT 'FACT_RETURNS'        ,                COUNT(*)        FROM FACT_RETURNS        PARTITION (ARCHV_DT_202506) UNION ALL
SELECT 'FACT_CLAIMS'         ,                COUNT(*)        FROM FACT_CLAIMS         PARTITION (ARCHV_DT_202506) UNION ALL
SELECT 'FACT_ADJUSTMENTS'    ,                COUNT(*)        FROM FACT_ADJUSTMENTS    PARTITION (ARCHV_DT_202506)
ORDER BY table_name;

===
SET SERVEROUTPUT ON SIZE UNLIMITED
DECLARE
   v_table_owner  VARCHAR2(128) := 'YOUR_SCHEMA_NAME';   -- ← CHANGE THIS
   v_table_name   VARCHAR2(128) := 'YOUR_TABLE_NAME';    -- ← CHANGE THIS

   v_sql          VARCHAR2(4000);
   v_count        INTEGER := 0;
BEGIN
   DBMS_OUTPUT.PUT_LINE('=== STARTING DISABLE OF FOREIGN KEYS ===');
   DBMS_OUTPUT.PUT_LINE('Target table: ' || v_table_owner || '.' || v_table_name);
   DBMS_OUTPUT.PUT_LINE('');

   FOR rec IN (
       SELECT owner, table_name, constraint_name
       FROM dba_constraints
       WHERE constraint_type = 'R'
         AND ( (r_owner, r_constraint_name) IN   -- FKs that point TO our table (child tables)
               (SELECT owner, constraint_name
                FROM dba_constraints
                WHERE owner = v_table_owner
                  AND table_name = v_table_name
                  AND constraint_type IN ('P','U')
               )
            OR                                 -- FKs that our table points FROM (parent references)
               (owner = v_table_owner AND table_name = v_table_name)
             )
         AND delete_rule = 'NO ACTION'   -- optional filter, remove if you want ALL
   )
   LOOP
      BEGIN
         v_sql := 'ALTER TABLE '||rec.owner||'.'||rec.table_name||
                  ' DISABLE CONSTRAINT '||rec.constraint_name;

         EXECUTE IMMEDIATE v_sql;

         DBMS_OUTPUT.PUT_LINE('DISABLED  → '||rec.owner||'.'||rec.table_name||'.'||rec.constraint_name);

         v_count := v_count + 1;

      EXCEPTION
         WHEN OTHERS THEN
            DBMS_OUTPUT.PUT_LINE('FAILED to disable → '||
                                 rec.owner||'.'||rec.table_name||'.'||rec.constraint_name||
                                 ' | Error: '||SQLERRM);
      END;
   END LOOP;

   DBMS_OUTPUT.PUT_LINE('');
   DBMS_OUTPUT.PUT_LINE('=== DISABLE PHASE FINISHED – '||v_count||' constraints disabled ===');
   DBMS_OUTPUT.PUT_LINE('You can now safely run the TOAD copy with:');
   DBMS_OUTPUT.PUT_LINE('PARTITION (ARCHV_DT_202506) AND ROWNUM <= 500');
END;
/



enable

SET SERVEROUTPUT ON SIZE UNLIMITED
DECLARE
   v_table_owner  VARCHAR2(128) := 'YOUR_SCHEMA_NAME';   -- ← SAME AS ABOVE
   v_table_name   VARCHAR2(128) := 'YOUR_TABLE_NAME';    -- ← SAME AS ABOVE

   v_sql          VARCHAR2(4000);
   v_count        INTEGER := 0;
BEGIN
   DBMS_OUTPUT.PUT_LINE('=== STARTING ENABLE NOVALIDATE OF FOREIGN KEYS ===');

   FOR rec IN (
       SELECT owner, table_name, constraint_name
       FROM dba_constraints
       WHERE constraint_type = 'R'
         AND ( (r_owner, r_constraint_name) IN
               (SELECT owner, constraint_name
                FROM dba_constraints
                WHERE owner = v_table_owner
                  AND table_name = v_table_name
                  AND constraint_type IN ('P','U')
               )
            OR (owner = v_table_owner AND table_name = v_table_name)
             )
   )
   LOOP
      BEGIN
         v_sql := 'ALTER TABLE '||rec.owner||'.'||rec.table_name||
                  ' ENABLE NOVALIDATE CONSTRAINT '||rec.constraint_name;

         EXECUTE IMMEDIATE v_sql;

         DBMS_OUTPUT.PUT_LINE('ENABLED NOVALIDATE → '||rec.owner||'.'||rec.table_name||'.'||rec.constraint_name);

         v_count := v_count + 1;

      EXCEPTION
         WHEN OTHERS THEN
            DBMS_OUTPUT.PUT_LINE('FAILED to enable → '||
                                 rec.owner||'.'||rec.table_name||'.'||rec.constraint_name||
                                 ' | Error: '||SQLERRM);
      END;
   END LOOP;

   DBMS_OUTPUT.PUT_LINE('');
   DBMS_OUTPUT.PUT_LINE('=== ALL DONE – '||v_count||' constraints re-enabled with NOVALIDATE ===');
END;
/






======

Step 1 – Disable all foreign keys that can block your insert (run in the TARGET database)

SQL
-- Disable every FK that references your table OR that your table references
BEGIN
   FOR rec IN (
       SELECT owner, table_name, constraint_name
       FROM dba_constraints
       WHERE constraint_type = 'R'
         AND (r_owner, r_constraint_name) IN (
               SELECT owner, constraint_name
               FROM dba_constraints
               WHERE owner = 'YOUR_TARGET_SCHEMA'     -- ← change to your schema
                 AND table_name = 'YOUR_TABLE_NAME'   -- ← change to your actual table name
                 AND constraint_type IN ('P','U')
             )
          OR (owner = 'YOUR_TARGET_SCHEMA' 
              AND table_name = 'YOUR_TABLE_NAME')
   ) LOOP
       EXECUTE IMMEDIATE 
           'ALTER TABLE '||rec.owner||'.'||rec.table_name||
           ' DISABLE CONSTRAINT '||rec.constraint_name;
   END LOOP;
END;
/

Step 2 – Now run the copy in TOAD with this WHERE clause (100% works)

SQL
PARTITION (ARCHV_DT_202506) AND ROWNUM <= 500

Step 3 – After the copy finishes successfully, re-enable all the FKs (with NOVALIDATE so it doesn’t scan millions of rows)

SQL
BEGIN
   FOR rec IN (
       SELECT owner, table_name, constraint_name
       FROM dba_constraints
       WHERE constraint_type = 'R'
         AND (r_owner, r_constraint_name) IN (
               SELECT owner, constraint_name
               FROM dba_constraints
               WHERE owner = 'YOUR_TARGET_SCHEMA'
                 AND table_name = 'YOUR_TABLE_NAME'
                 AND constraint_type IN ('P','U')
             )
          OR (owner = 'YOUR_TARGET_SCHEMA' 
              AND table_name = 'YOUR_TABLE_NAME')
   ) LOOP
       EXECUTE IMMEDIATE 
           'ALTER TABLE '||rec.owner||'.'||rec.table_name||
           ' ENABLE NOVALIDATE CONSTRAINT '||rec.constraint_name;
   END LOOP;
END;
/

Just replace these two placeholders in both scripts:

Wednesday, December 3, 2025

drop

 
-- ========================================================
-- FINAL WORKING SCRIPT – DROP COLUMN + PARALLEL 8 (NO PLS-00103)
-- ========================================================
-- >>> EDIT ONLY THESE THREE LINES <<<
DEFINE owner       = 'HR'
DEFINE table_name  = 'BIG_TABLE'
DEFINE column_name = 'OLD_COLUMN'
-- ========================================================
PROMPT === 1. Mark column as UNUSED (instant) ===
ALTER TABLE &&owner..&&table_name SET UNUSED COLUMN &&column_name;
COMMIT;
PROMPT Column &&column_name is now UNUSED and invisible to all new transactions.
-- ========================================================
PROMPT === 2. Submit background job with PARALLEL 8 ===
DECLARE
    v_job_action  CLOB;
BEGIN
    -- First remove any old job with the same name
    BEGIN
        DBMS_SCHEDULER.DROP_JOB(job_name => 'DROP_UNUSED_&&table_name._P8', force => TRUE);
    EXCEPTION WHEN OTHERS THEN NULL;
    END;
    -- Build the job action as CLOB (this avoids all quoting nightmares)
    v_job_action := q'[
BEGIN
    EXECUTE IMMEDIATE 'ALTER SESSION ENABLE PARALLEL DML';
    EXECUTE IMMEDIATE 'ALTER SESSION FORCE PARALLEL DDL PARALLEL 8';
    -- Do the heavy physical drop with checkpointing
    EXECUTE IMMEDIATE 
        'ALTER TABLE ]' || '&&owner..&&table_name' || q'[ DROP UNUSED COLUMNS CHECKPOINT 10000';
    DBMS_OUTPUT.PUT_LINE('Physical drop with PARALLEL 8 completed successfully');
END;]';
    DBMS_SCHEDULER.CREATE_JOB (
        job_name     => 'DROP_UNUSED_&&table_name._P8',
        job_type     => 'PLSQL_BLOCK',
        job_action   => v_job_action,
        start_date   => SYSTIMESTAMP,
        enabled      => TRUE,
        auto_drop    => TRUE,
        comments     => 'Physical drop of unused columns with PARALLEL 8'
    );
    DBMS_OUTPUT.PUT_LINE('Background job submitted successfully with PARALLEL 8');
END;
/
PROMPT 
PROMPT Job is now running in the background with 8 parallel processes.
PROMPT You can close TOAD/SQL Developer immediately — everything continues on the server.
-- ========================================================
PROMPT === MONITORING COMMANDS (run any time) ===
PROMPT 
PROMPT -- Job status
PROMPT SELECT job_name, status, actual_start_date, run_duration, error#
PROMPT FROM user_scheduler_job_run_details
PROMPT WHERE job_name = 'DROP_UNUSED_&&table_name._P8';
PROMPT -- Real-time progress (you will see ~8 rows, one per parallel slave)
PROMPT SELECT sid, opname, sofar, totalwork,
PROMPT        ROUND(sofar/totalwork*100,2)||'%' pct, elapsed_seconds/60 mins
PROMPT FROM v$session_longops
PROMPT WHERE opname = 'Drop Column' AND target LIKE '%&&table_name%';
PROMPT Done! Column already disappeared from queries, physical cleanup running safely in background.



====

-- ========================================================
-- SAFE DROP COLUMN + PARALLEL 8 - FULL SCRIPT (Dec 2025)
-- ========================================================
-- >>>>>>>> EDIT THESE 4 LINES ONLY <<<<<<<<
DEFINE owner       = 'YOUR_SCHEMA_NAME'
DEFINE table_name  = 'YOUR_TABLE_NAME'
DEFINE column_name = 'YOUR_COLUMN_NAME'

-- Set to 'Y' ONLY if you really want to kill the stuck session (rarely needed now)
DEFINE KILL_CURRENT_SESSION = 'N'   -- Keep 'N' in almost all cases

-- ========================================================
PROMPT 
PROMPT === STEP 1: Mark column as UNUSED (instant, zero downtime) ===
ALTER TABLE &&owner..&&table_name SET UNUSED COLUMN &&column_name;
COMMIT;

PROMPT Column &&column_name is now invisible to all new queries and DESC!

-- ========================================================
PROMPT 
PROMPT === STEP 2: Schedule PHYSICAL DROP with PARALLEL 8 in background ===
BEGIN
    -- Clean any previous job with same name
    BEGIN
        DBMS_SCHEDULER.DROP_JOB('DROP_UNUSED_&&table_name||'_PAR8', FORCE => TRUE);
    EXCEPTION WHEN OTHERS THEN NULL;
    END;

    DBMS_SCHEDULER.CREATE_JOB (
        job_name        => 'DROP_UNUSED_&&table_name||'_PAR8',
        job_type        => 'PLSQL_BLOCK',
        job_action      => q'[
            BEGIN
                -- Enable parallel DML inside the job
                EXECUTE IMMEDIATE 'ALTER SESSION ENABLE PARALLEL DML';
                EXECUTE IMMEDIATE 'ALTER SESSION FORCE PARALLEL DDL PARALLEL 8';

                DBMS_OUTPUT.PUT_LINE('Starting physical drop with PARALLEL 8...');

                EXECUTE IMMEDIATE 
                    'ALTER TABLE &&owner..&&table_name DROP UNUSED COLUMNS CHECKPOINT 10000';

                DBMS_OUTPUT.PUT_LINE('Physical drop completed successfully with PARALLEL 8');
            EXCEPTION
                WHEN OTHERS THEN
                    DBMS_OUTPUT.PUT_LINE('ERROR: '||SQLERRM);
                    RAISE;
            END;
        ]',
        start_date      => SYSTIMESTAMP + 5/1440,   -- start 5 seconds from now
        enabled         => TRUE,
        auto_drop       => TRUE,
        comments        => 'Physical drop of unused column with PARALLEL 8'
    );

    DBMS_OUTPUT.PUT_LINE('Background job with PARALLEL 8 submitted successfully!');
END;
/

PROMPT 
PROMPT Job is now running in the background with 8 parallel processes.
PROMPT You can safely close TOAD/SQL Developer now — everything continues on the server.

-- ========================================================
PROMPT 
PROMPT === MONITORING QUERIES (copy-paste & run anytime) ===
PROMPT 
PROMPT -- 1. Job status
PROMPT SELECT job_name, status, actual_start_date, run_duration, error#
PROMPT FROM   user_scheduler_job_run_details
PROMPT WHERE  job_name = 'DROP_UNUSED_&&table_name||'_PAR8'
PROMPT ORDER BY log_date DESC;

PROMPT 
PROMPT -- 2. Real-time progress with PARALLEL 8 slaves
PROMPT SELECT sid, username, opname, target, sofar, totalwork,
PROMPT        ROUND(sofar/totalwork*100,2) || '%' pct_done,
PROMPT        elapsed_seconds/60 mins_running, message
PROMPT FROM   v$session_longops
PROMPT WHERE  opname = 'Drop Column'
PROMPT   AND  target LIKE '%&&table_name%'
PROMPT   AND  sofar <> totalwork;

PROMPT 
PROMPT -- 3. See the 8 parallel execution servers in action
PROMPT SELECT sid, px_server#, px_server_group, degree
PROMPT FROM   v$session
PROMPT WHERE  program LIKE '%(P0%'
PROMPT   AND    sql_id = (SELECT sql_id FROM v$session WHERE program LIKE '%scheduler%');

PROMPT 
PROMPT -- 4. Confirm column is gone after job finishes
PROMPT SELECT column_name, hidden_column, segment_column_id
PROMPT FROM   user_tab_cols
PROMPT WHERE  table_name = UPPER('&&table_name')
PROMPT AND    column_name = UPPER('&&column_name');

PROMPT 
PROMPT Script finished. Your table stays 100% available the whole time.
PROMPT Typical speed gain with PARALLEL 8: 5–7× faster than serial drop.

-- End of script

Monday, December 1, 2025

ORA-14097

-- ONE SINGLE QUERY → generates perfect plain-SQL commands
-- Uses the REAL SYS_C... names from PROD.SALES
SELECT 
    'ALTER TABLE sales_staging ADD "' || column_name || '" VARCHAR2(1);' || CHR(10) ||
    'ALTER TABLE sales_staging SET UNUSED COLUMN "' || column_name || '";'
    AS plain_sql_commands
FROM dba_tab_cols
WHERE owner       = 'PROD'
  AND table_name  = 'SALES'
  AND column_name LIKE 'SYS_C%' 
  AND hidden_column = 'YES'
ORDER BY internal_column_id;

==========

STEP 2


-- Compare physical column order (INTERNAL_COLUMN_ID) between source and staging
WITH source_cols AS (
   SELECT internal_column_id, column_name, data_type, hidden_column
   FROM   dba_tab_cols
   WHERE  owner = 'PROD' AND table_name = 'SALES'
   ORDER BY internal_column_id
),
staging_cols AS (
   SELECT internal_column_id, column_name, data_type, hidden_column
   FROM   dba_tab_cols
   WHERE  owner = 'PROD' AND table_name = 'SALES_STAGING'
   ORDER BY internal_column_id
)
SELECT 
   s.internal_column_id AS source_pos,
   s.column_name AS source_col,
   t.internal_column_id AS staging_pos,
   t.column_name AS staging_col,
   CASE WHEN s.internal_column_id = t.internal_column_id THEN 'MATCH' ELSE 'MISMATCH' END AS status
FROM   source_cols s
FULL OUTER JOIN staging_cols t
   ON s.internal_column_id = t.internal_column_id
   AND NVL(s.column_name, '?') = NVL(t.column_name, '?')
ORDER BY NVL(s.internal_column_id, t.internal_column_id);


++++++++++

-- This works 100% identically to the NUMBER version
-- (Oracle officially ignores datatype for UNUSED columns during exchange)
BEGIN
   FOR i IN 1..40 LOOP
      EXECUTE IMMEDIATE 
         'ALTER TABLE sales_staging ADD dgh_' || LPAD(i,3,'0') || ' VARCHAR2(1)';
      EXECUTE IMMEDIATE 
         'ALTER TABLE sales_staging SET UNUSED COLUMN dgh_' || LPAD(i,3,'0');
   END LOOP;
END;
/
===========
-- CORRECTED VERSION – works in all tools
SELECT source,
       internal_column_id,
       column_name,
       data_type,
       data_length,
       data_precision,
       data_scale
FROM (
    -- STAGING minus PARTITIONED
    SELECT 'STAGING'    AS source,
           internal_column_id,
           column_name,
           data_type,
           data_length,
           data_precision,
           data_scale
    FROM   dba_tab_cols
    WHERE  owner       = 'PROD'            -- <<< change
    AND    table_name  = 'SALES_STAGING'   -- <<< change

    MINUS

    SELECT 'PARTITIONED' AS source,
           internal_column_id,
           column_name,
           data_type,
           data_length,
           data_precision,
           data_scale
    FROM   dba_tab_cols
    WHERE  owner       = 'PROD'            --<<< change
    AND    table_name  = 'SALES'           --<<< change

    UNION ALL

    -- PARTITIONED minus STAGING
    SELECT 'PARTITIONED' AS source,
           internal_column_id,
           column_name,
           data_type,
           data_length,
           data_precision,
           data_scale
    FROM   dba_tab_cols
    WHERE  owner       = 'PROD'
    AND    table_name  = 'SALES'

    MINUS

    SELECT 'STAGING' AS source,
           internal_column_id,
           column_name,
           data_type,
           data_length,
           data_precision,
           data_scale
    FROM   dba_tab_cols
    WHERE  owner       = 'PROD'
    AND    table_name  = 'SALES_STAGING'
)
ORDER BY internal_column_id, source;   -- only ONE ORDER BY at the very end

-----

-- PERFECT ONE-SHOT QUERY – shows exactly what causes ORA-14097
WITH staging AS (
    SELECT 
        column_id,
        column_name,
        data_type,
        data_type_mod,
        data_type_owner,
        data_length,
        data_precision,
        data_scale,
        char_length,          -- correct column in DBA_TAB_COLUMNS
        char_used,            -- B or C – correct column
        nullable,
        data_default          -- LONG! but we handle it safely below
    FROM dba_tab_columns
    WHERE owner       = 'YOUR_SCHEMA'
      AND table_name  = 'SALES_STAGING'
),
partition_def AS (
    SELECT 
        column_id,
        column_name,
        data_type,
        data_type_mod,
        data_type_owner,
        data_length,
        data_precision,
        data_scale,
        char_col_decl_length  AS char_length,   -- correct name in DBA_PART_COL_STATISTICS
        char_used,
        nullable,
        NULL                  AS data_default   -- this column does NOT exist here → always NULL
    FROM dba_part_col_statistics
    WHERE owner          = 'YOUR_SCHEMA'
      AND table_name     = 'SALES'
      AND partition_name = 'P_DEC2025'
)
SELECT 
    src,
    column_id,
    column_name,
    data_type,
    char_used,
    nullable,
    data_precision,
    data_scale,
    char_length,
    CASE WHEN data_default IS NULL THEN 'NULL'
         WHEN LENGTH(data_default) > 50 THEN SUBSTR(data_default,1,50)||'...(truncated)'
         ELSE TO_CHAR(data_default)
    END AS data_default_snippet
FROM (
    SELECT 'STAGING ≠ PARTITION' AS src, s.*,
           'S' as which
    FROM staging s
    FULL OUTER JOIN partition_def p
      ON s.column_id = p.column_id
     AND NVL(s.column_name,'?') = NVL(p.column_name,'?')
    WHERE p.column_id IS NULL
       OR s.column_id IS NULL
       OR s.data_type          <> p.data_type
       OR NVL(s.data_type_mod,'X')   <> NVL(p.data_type_mod,'X')
       OR NVL(s.data_type_owner,'X') <> NVL(p.data_type_owner,'X')
       OR s.data_length        <> p.data_length
       OR NVL(s.data_precision,-999) <> NVL(p.data_precision,-999)
       OR NVL(s.data_scale,-999)     <> NVL(p.data_scale,-999)
       OR NVL(s.char_length,-999)    <> NVL(p.char_length,-999)
       OR NVL(s.char_used,'X')       <> NVL(p.char_used,'X')
       OR NVL(s.nullable,'X')        <> NVL(p.nullable,'X')
       OR (s.data_default IS NOT NULL AND p.data_default IS NULL)
       OR (s.data_default IS NULL     AND p.data_default IS NOT NULL)

    UNION ALL

    SELECT 'PARTITION ≠ STAGING', p.*,
           'P'
    FROM staging s
    FULL OUTER JOIN partition_def p
      ON s.column_id = p.column_id
     AND NVL(s.column_name,'?') = NVL(p.column_name,'?')
    WHERE p.column_id IS NULL
       OR s.column_id IS NULL
       OR s.data_type          <> p.data_type
       OR NVL(s.data_type_mod,'X')   <> NVL(p.data_type_mod,'X')
       OR NVL(s.data_type_owner,'X') <> NVL(p.data_type_owner,'X')
       OR s.data_length        <> p.data_length
       OR NVL(s.data_precision,-999) <> NVL(p.data_precision,-999)
       OR NVL(s.data_scale,-999)     <> NVL(p.data_scale,-999)
       OR NVL(s.char_length,-999)    <> NVL(p.char_length,-999)
       OR NVL(s.char_used,'X')       <> NVL(p.char_used,'X')
       OR NVL(s.nullable,'X')        <> NVL(p.nullable,'X')
       OR (s.data_default IS NOT NULL AND p.data_default IS NULL)
       OR (s.data_default IS NULL     AND p.data_default IS NOT NULL)
)
ORDER BY column_id, src;

-- 1. Structure of your staging table
SELECT 
    column_id,
    column_name,
    data_type,
    data_type_mod,
    data_type_owner,
    data_length,
    data_precision,
    data_scale,
    char_length,
    char_used,        -- B or C  (this is a VERY common hidden mismatch)
    nullable,
    data_default
FROM   user_tab_columns
WHERE  table_name = 'SALES_STAGING'
ORDER BY column_id;
-- 2. Structure of the exact partition you want to exchange with
SELECT 
    column_id,
    column_name,
    data_type,
    data_type_mod,
    data_type_owner,
    data_length,
    data_precision,
    data_scale,
    char_length,
    char_used,        -- B or C
    nullable,
    data_default
FROM   user_part_col_statistics   -- this view shows the real partition definition
WHERE  table_name = 'SALES'        -- your partitioned table
AND    partition_name = 'P_DEC2025'   -- <<< change to your real partition name
ORDER BY column_id;

--------

-- One-liner that shows you the differences immediately
SELECT * FROM (
    SELECT column_id, column_name, data_type, char_used, nullable, data_default, 'STAGING' src
    FROM user_tab_columns WHERE table_name = 'SALES_STAGING'
  MINUS
    SELECT column_id, column_name, data_type, char_used, nullable, data_default, 'PARTITION' 
    FROM user_part_col_statistics 
    WHERE table_name = 'SALES' AND partition_name = 'P_DEC2025'
)
UNION ALL
SELECT * FROM (
    SELECT column_id, column_name, data_type, char_used, nullable, data_default, 'PARTITION'
    FROM user_part_col_statistics 
    WHERE table_name = 'SALES' AND partition_name = 'P_DEC2025'
  MINUS
    SELECT column_id, column_name, data_type, char_used, nullable, data_default, 'STAGING'
    FROM user_tab_columns WHERE table_name = 'SALES_STAGING'
)
ORDER BY column_id, src;