diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f7f44ed3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git/ +.github/ +oracle-rac/ +tests/0-inputs/ +tests/1-environments/ +tests/2-prebuilt/ +tests/3-generated/ +tests/.work/ +tests/scripts/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e36a218f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +tests/2-prebuilt/redo/** filter=lfs diff=lfs merge=lfs -text +tests/2-prebuilt/schema/** filter=lfs diff=lfs merge=lfs -text +tests/2-prebuilt/expected/** filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/redo-log-tests.yaml b/.github/workflows/redo-log-tests.yaml new file mode 100644 index 00000000..44152d36 --- /dev/null +++ b/.github/workflows/redo-log-tests.yaml @@ -0,0 +1,68 @@ +name: Redo Log Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +env: + CACHE_IMAGE: ghcr.io/${{ github.repository_owner }}/openlogreplicator:ci + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + packages: write + steps: + - uses: actions/checkout@v4 + with: + lfs: true + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build OLR image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.dev + load: true + tags: olr-dev:latest + cache-from: type=registry,ref=${{ env.CACHE_IMAGE }} + cache-to: type=registry,ref=${{ env.CACHE_IMAGE }},mode=max + build-args: | + BUILD_TYPE=Debug + UIDOLR=1001 + GIDOLR=1001 + WITHORACLE=1 + WITHKAFKA=1 + WITHPROTOBUF=1 + WITHPROMETHEUS=1 + WITHTESTS=1 + + - name: Download fixtures from sql-tests-free23 + uses: dawidd6/action-download-artifact@v6 + with: + workflow: sql-tests-free23.yaml + name: test-artifacts-free-23 + path: tests/3-generated/ + if_no_artifact_found: warn + + - name: Download fixtures from sql-tests-xe21 + uses: dawidd6/action-download-artifact@v6 + with: + workflow: sql-tests-xe21.yaml + name: test-artifacts-xe-21 + path: tests/3-generated/ + if_no_artifact_found: warn + + - name: Run redo log tests + run: make test-redo diff --git a/.github/workflows/sql-tests-free23.yaml b/.github/workflows/sql-tests-free23.yaml new file mode 100644 index 00000000..c8c68c52 --- /dev/null +++ b/.github/workflows/sql-tests-free23.yaml @@ -0,0 +1,60 @@ +name: "SQL Tests: Oracle Free 23" + +on: + push: + branches: [master] + workflow_dispatch: + +env: + CACHE_IMAGE: ghcr.io/${{ github.repository_owner }}/openlogreplicator:ci + ORACLE_TARGET: free-23 + +jobs: + generate: + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build OLR image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.dev + load: true + tags: olr-dev:latest + cache-from: type=registry,ref=${{ env.CACHE_IMAGE }} + cache-to: type=registry,ref=${{ env.CACHE_IMAGE }},mode=max + build-args: | + BUILD_TYPE=Debug + UIDOLR=1001 + GIDOLR=1001 + WITHORACLE=1 + WITHKAFKA=1 + WITHPROTOBUF=1 + WITHPROMETHEUS=1 + WITHTESTS=1 + + - name: Start containers + run: make -C tests/1-environments/${{ env.ORACLE_TARGET }} up + + - name: Generate all fixtures + run: make -C tests/1-environments/${{ env.ORACLE_TARGET }} test-sql + + - name: Upload generated fixtures + uses: actions/upload-artifact@v4 + with: + name: test-artifacts-${{ env.ORACLE_TARGET }} + path: tests/3-generated/ + retention-days: 90 diff --git a/.github/workflows/sql-tests-xe21.yaml b/.github/workflows/sql-tests-xe21.yaml new file mode 100644 index 00000000..03ac90a0 --- /dev/null +++ b/.github/workflows/sql-tests-xe21.yaml @@ -0,0 +1,60 @@ +name: "SQL Tests: Oracle XE 21" + +on: + push: + branches: [master] + workflow_dispatch: + +env: + CACHE_IMAGE: ghcr.io/${{ github.repository_owner }}/openlogreplicator:ci + ORACLE_TARGET: xe-21 + +jobs: + generate: + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build OLR image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.dev + load: true + tags: olr-dev:latest + cache-from: type=registry,ref=${{ env.CACHE_IMAGE }} + cache-to: type=registry,ref=${{ env.CACHE_IMAGE }},mode=max + build-args: | + BUILD_TYPE=Debug + UIDOLR=1001 + GIDOLR=1001 + WITHORACLE=1 + WITHKAFKA=1 + WITHPROTOBUF=1 + WITHPROMETHEUS=1 + WITHTESTS=1 + + - name: Start containers + run: make -C tests/1-environments/${{ env.ORACLE_TARGET }} up + + - name: Generate all fixtures + run: make -C tests/1-environments/${{ env.ORACLE_TARGET }} test-sql + + - name: Upload generated fixtures + uses: actions/upload-artifact@v4 + with: + name: test-artifacts-${{ env.ORACLE_TARGET }} + path: tests/3-generated/ + retention-days: 90 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..fdf9361d --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +build/ +cmake-build-*/ +.idea/ +.vscode/ +*.swp +*~ +.DS_Store +tests/3-generated/ +tests/.work/ diff --git a/CMakeLists.txt b/CMakeLists.txt index f311572d..91d10978 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -127,6 +127,10 @@ if (WITH_PROTOBUF) endif () add_subdirectory(src) +if (WITH_TESTS) + enable_testing() + add_subdirectory(tests) +endif () target_link_libraries(OpenLogReplicator Threads::Threads) diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 00000000..7167abb6 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,173 @@ +# syntax=docker/dockerfile:1 +# +# Dockerfile.dev — Optimized for local dev builds +# Splits dependencies into cached layers + uses ccache for incremental compilation. +# +# Usage: +# GIDOLR=$(id -g) UIDOLR=$(id -u) docker compose build olr + +ARG IMAGE=debian +ARG VERSION=13.0 +FROM ${IMAGE}:${VERSION} + +ARG ARCH=x86_64 +ARG GIDOLR=1001 +ARG UIDOLR=1001 +ARG GIDORA=54322 +ARG BUILD_TYPE=Debug +ARG WITHKAFKA +ARG WITHPROMETHEUS +ARG WITHORACLE +ARG WITHPROTOBUF +ARG WITHTESTS + +ENV LC_ALL=C +ENV LANG=en_US.UTF-8 +ENV ORACLE_MAJOR=23 +ENV ORACLE_MINOR=26 +ENV PROTOBUF_VERSION_DIR=21.12 +ENV PROTOBUF_VERSION=3.21.12 +ENV RAPIDJSON_VERSION=1.1.0 +ENV LIBRDKAFKA_VERSION=2.13.0 +ENV PROMETHEUS_VERSION=1.3.0 +ENV OPENLOGREPLICATOR_VERSION=local +ENV LD_LIBRARY_PATH=/opt/instantclient_${ORACLE_MAJOR}_${ORACLE_MINOR}:/opt/librdkafka/lib:/opt/prometheus/lib:/opt/protobuf/lib +ENV BUILDARGS="-DCMAKE_BUILD_TYPE=${BUILD_TYPE} -DWITH_RAPIDJSON=/opt/rapidjson -S ../ -B ./" +ENV BUILDARGS="${BUILDARGS}${WITHKAFKA:+ -DWITH_RDKAFKA=/opt/librdkafka}" +ENV BUILDARGS="${BUILDARGS}${WITHPROMETHEUS:+ -DWITH_PROMETHEUS=/opt/prometheus}" +ENV BUILDARGS="${BUILDARGS}${WITHORACLE:+ -DWITH_OCI=/opt/instantclient_${ORACLE_MAJOR}_${ORACLE_MINOR}}" +ENV BUILDARGS="${BUILDARGS}${WITHPROTOBUF:+ -DWITH_PROTOBUF=/opt/protobuf}" +ENV BUILDARGS="${BUILDARGS}${WITHTESTS:+ -DWITH_TESTS=ON}" +ENV COMPILEKAFKA="${WITHKAFKA:+1}" +ENV COMPILEPROMETHEUS="${WITHPROMETHEUS:+1}" +ENV COMPILEORACLE="${WITHORACLE:+1}" +ENV COMPILEPROTOBUF="${WITHPROTOBUF:+1}" +ENV DEBIAN_FRONTEND=noninteractive + +COPY scripts/run.sh /opt + +# Layer 1: System packages + ccache +RUN set -eu && \ + apt-get update && \ + apt-get -y install file gcc g++ libaio1t64 libasan8 libubsan1 libtool libz-dev make patch unzip wget cmake git curl ccache && \ + ln -s libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1 + +# Layer 2: RapidJSON +RUN set -eu && \ + cd /opt && \ + wget -q https://github.com/Tencent/rapidjson/archive/refs/tags/v${RAPIDJSON_VERSION}.tar.gz && \ + tar xzf v${RAPIDJSON_VERSION}.tar.gz && \ + rm v${RAPIDJSON_VERSION}.tar.gz && \ + ln -s rapidjson-${RAPIDJSON_VERSION} rapidjson && \ + if [ "${RAPIDJSON_VERSION}" = "1.1.0" ]; then \ + cd rapidjson && \ + wget -q https://github.com/Tencent/rapidjson/commit/3b2441b87f99ab65f37b141a7b548ebadb607b96.diff && \ + patch -p1 < 3b2441b87f99ab65f37b141a7b548ebadb607b96.diff && \ + rm 3b2441b87f99ab65f37b141a7b548ebadb607b96.diff ; \ + fi + +# Layer 3: Oracle Instant Client +RUN set -eu && \ + if [ "${COMPILEORACLE}" != "" ]; then \ + cd /opt && \ + wget -q https://download.oracle.com/otn_software/linux/instantclient/${ORACLE_MAJOR}${ORACLE_MINOR}000/instantclient-basic-linux.x64-${ORACLE_MAJOR}.${ORACLE_MINOR}.0.0.0.zip && \ + unzip -o instantclient-basic-linux.x64-${ORACLE_MAJOR}.${ORACLE_MINOR}.0.0.0.zip && \ + rm instantclient-basic-linux.x64-${ORACLE_MAJOR}.${ORACLE_MINOR}.0.0.0.zip && \ + rm -rf META-INF && \ + wget -q https://download.oracle.com/otn_software/linux/instantclient/${ORACLE_MAJOR}${ORACLE_MINOR}000/instantclient-sdk-linux.x64-${ORACLE_MAJOR}.${ORACLE_MINOR}.0.0.0.zip && \ + unzip -o instantclient-sdk-linux.x64-${ORACLE_MAJOR}.${ORACLE_MINOR}.0.0.0.zip && \ + rm instantclient-sdk-linux.x64-${ORACLE_MAJOR}.${ORACLE_MINOR}.0.0.0.zip && \ + rm -rf META-INF ; \ + fi + +# Layer 4: Protobuf (~5-7 min, cached unless version changes) +RUN set -eu && \ + if [ "${COMPILEPROTOBUF}" != "" ]; then \ + cd /opt && \ + wget -q https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOBUF_VERSION_DIR}/protobuf-cpp-${PROTOBUF_VERSION}.tar.gz && \ + tar xzf protobuf-cpp-${PROTOBUF_VERSION}.tar.gz && \ + rm protobuf-cpp-${PROTOBUF_VERSION}.tar.gz && \ + cd /opt/protobuf-${PROTOBUF_VERSION} && \ + ./configure --prefix=/opt/protobuf && \ + make && \ + make install ; \ + fi + +# Layer 5: librdkafka (~2-3 min, cached unless version changes) +RUN set -eu && \ + if [ "${COMPILEKAFKA}" != "" ]; then \ + cd /opt && \ + wget -q https://github.com/confluentinc/librdkafka/archive/refs/tags/v${LIBRDKAFKA_VERSION}.tar.gz && \ + tar xzf v${LIBRDKAFKA_VERSION}.tar.gz && \ + rm v${LIBRDKAFKA_VERSION}.tar.gz && \ + cd /opt/librdkafka-${LIBRDKAFKA_VERSION} && \ + ./configure --prefix=/opt/librdkafka && \ + make && \ + make install ; \ + fi + +# Layer 6: Prometheus +RUN set -eu && \ + if [ "${COMPILEPROMETHEUS}" != "" ]; then \ + cd /opt && \ + wget -q https://github.com/jupp0r/prometheus-cpp/releases/download/v${PROMETHEUS_VERSION}/prometheus-cpp-with-submodules.tar.gz && \ + tar xzf prometheus-cpp-with-submodules.tar.gz && \ + rm prometheus-cpp-with-submodules.tar.gz && \ + cd /opt/prometheus-cpp-with-submodules && \ + mkdir _build && \ + cd _build && \ + cmake .. -DBUILD_SHARED_LIBS=ON -DCMAKE_INSTALL_PREFIX:PATH=/opt/prometheus -DENABLE_PUSH=OFF -DENABLE_COMPRESSION=OFF && \ + cmake --build . --parallel 4 && \ + ctest -V && \ + cmake --install . ; \ + fi + +# --- Source changes only invalidate from here down --- +COPY . /opt/OpenLogReplicator-local + +# Layer 7: Build OLR (ccache persists across rebuilds via BuildKit cache mount) +RUN --mount=type=cache,target=/root/.ccache,id=olr-ccache \ + set -eu && \ + export CCACHE_DIR=/root/.ccache && \ + cd /opt/OpenLogReplicator-local && \ + if [ "${COMPILEPROTOBUF}" != "" ]; then \ + cd proto && \ + /opt/protobuf/bin/protoc OraProtoBuf.proto --cpp_out=. && \ + mv OraProtoBuf.pb.cc ../src/common/OraProtoBuf.pb.cpp && \ + mv OraProtoBuf.pb.h ../src/common/OraProtoBuf.pb.h && \ + cd .. ; \ + fi && \ + mkdir cmake-build-${BUILD_TYPE}-${ARCH} && \ + cd cmake-build-${BUILD_TYPE}-${ARCH} && \ + cmake ${BUILDARGS} \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache && \ + cmake --build ./ --target OpenLogReplicator -j && \ + if [ "${WITHTESTS}" != "" ]; then \ + cmake --build ./ --target olr_tests -j ; \ + fi && \ + mkdir -p /opt/OpenLogReplicator/log /opt/OpenLogReplicator/tmp /opt/OpenLogReplicator/scripts && \ + cp ./OpenLogReplicator /opt/OpenLogReplicator && \ + cp -p /opt/OpenLogReplicator-local/scripts/gencfg.sql /opt/OpenLogReplicator/scripts/gencfg.sql + +# Layer 8: User setup +RUN set -eu && \ + mkdir -p /home/user1 && \ + groupadd -g ${GIDOLR} user1 && \ + if [ "${GIDOLR}" != "${GIDORA}" ]; then \ + groupadd -g ${GIDORA} oracle && \ + useradd -u ${UIDOLR} user1 -g user1 -G oracle -d /home/user1 ; \ + else \ + useradd -u ${UIDOLR} user1 -g user1 -d /home/user1 ; \ + fi && \ + chown -R user1:user1 /home/user1 && \ + chown -R user1:user1 /opt/OpenLogReplicator && \ + chown -R user1:user1 /opt/OpenLogReplicator-local/cmake-build-* + +USER user1:oracle +RUN set -eu && \ + export LD_LIBRARY_PATH=/opt/instantclient_${ORACLE_MAJOR}_${ORACLE_MINOR}:/opt/librdkafka/lib:/opt/prometheus/lib:/opt/protobuf/lib && \ + /opt/OpenLogReplicator/OpenLogReplicator --version + +WORKDIR /opt/OpenLogReplicator +ENTRYPOINT ["/opt/run.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..9709d37e --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +OLR_IMAGE ?= olr-dev:latest +CACHE_IMAGE ?= ghcr.io/bersler/openlogreplicator:ci +BUILD_TYPE ?= Debug +OLR_BUILD_DIR ?= /opt/OpenLogReplicator-local/cmake-build-$(BUILD_TYPE)-x86_64 + +.PHONY: build test-redo clean + +build: + docker buildx build \ + --build-arg BUILD_TYPE=$(BUILD_TYPE) \ + --build-arg UIDOLR=$$(id -u) \ + --build-arg GIDOLR=$$(id -g) \ + --build-arg GIDORA=54322 \ + --build-arg WITHORACLE=1 \ + --build-arg WITHKAFKA=1 \ + --build-arg WITHPROTOBUF=1 \ + --build-arg WITHPROMETHEUS=1 \ + --build-arg WITHTESTS=1 \ + --cache-from type=registry,ref=$(CACHE_IMAGE) \ + -t $(OLR_IMAGE) \ + --load \ + -f Dockerfile.dev . + +test-redo: + docker run --rm \ + -v $(CURDIR)/tests:/opt/OpenLogReplicator-local/tests \ + --entrypoint bash $(OLR_IMAGE) \ + -c "ctest --test-dir $(OLR_BUILD_DIR) --output-on-failure" + +clean: + rm -rf tests/3-generated tests/.work diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 00000000..006f8324 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Start script for Docker +# Copyright (C) 2018-2026 Adam Leszczynski (aleszczynski@bersler.com) +# +# This file is part of OpenLogReplicator +# +# Open Log Replicator is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as published +# by the Free Software Foundation; either version 3, or (at your option) +# any later version. +# +# Open Log Replicator is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +# Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Open Log Replicator; see the file LICENSE.txt If not see +# . + +cd /opt/OpenLogReplicator +FLAG_FILE=/opt/OpenLogReplicator/log/.olr_started.flag +if [ -x /opt/bin/OpenLogReplicator ]; then + OLR_EXEC=/opt/bin/OpenLogReplicator +else + OLR_EXEC=./OpenLogReplicator +fi + +if [ ! -f "$FLAG_FILE" ]; then + # first execution, provided arguments for startup + touch "$FLAG_FILE" + ${OLR_EXEC} "$@" 2>&1 | tee -a /opt/OpenLogReplicator/log/OpenLogReplicator.err +else + # subsequent execution, start normally + ${OLR_EXEC} 2>&1 | tee -a /opt/OpenLogReplicator/log/OpenLogReplicator.err +fi diff --git a/tests/0-inputs/basic-crud.sql b/tests/0-inputs/basic-crud.sql new file mode 100644 index 00000000..4efe6be0 --- /dev/null +++ b/tests/0-inputs/basic-crud.sql @@ -0,0 +1,64 @@ +-- basic-crud.sql: Simple INSERT/UPDATE/DELETE scenario for fixture generation. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: +-- The orchestrator script parses these and handles log switches separately +-- (ALTER SYSTEM SWITCH LOGFILE must run from CDB root, not PDB). + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_CDC'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_CDC PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_CDC ( + id NUMBER PRIMARY KEY, + name VARCHAR2(100), + val NUMBER +); + +ALTER TABLE TEST_CDC ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- DML: INSERTs +INSERT INTO TEST_CDC VALUES (1, 'Alice', 100); +INSERT INTO TEST_CDC VALUES (2, 'Bob', 200); +INSERT INTO TEST_CDC VALUES (3, 'Charlie', 300); +COMMIT; + +-- DML: UPDATE +UPDATE TEST_CDC SET val = 150, name = 'Alice Updated' WHERE id = 1; +COMMIT; + +-- DML: DELETE +DELETE FROM TEST_CDC WHERE id = 2; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/concurrent-updates.sql b/tests/0-inputs/concurrent-updates.sql new file mode 100644 index 00000000..90bbfd6c --- /dev/null +++ b/tests/0-inputs/concurrent-updates.sql @@ -0,0 +1,83 @@ +-- concurrent-updates.sql: Same row updated across rapid commits. +-- Tests before/after image ordering and transaction boundaries. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_CONCURRENT'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_CONCURRENT PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_CONCURRENT ( + id NUMBER PRIMARY KEY, + status VARCHAR2(20), + counter NUMBER, + note VARCHAR2(100) +); + +ALTER TABLE TEST_CONCURRENT ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- Seed rows +INSERT INTO TEST_CONCURRENT VALUES (1, 'new', 0, 'row one'); +INSERT INTO TEST_CONCURRENT VALUES (2, 'new', 0, 'row two'); +INSERT INTO TEST_CONCURRENT VALUES (3, 'new', 0, 'row three'); +COMMIT; + +-- Rapid updates to same row (id=1) across separate commits +UPDATE TEST_CONCURRENT SET status = 'pending', counter = 1 WHERE id = 1; +COMMIT; + +UPDATE TEST_CONCURRENT SET status = 'active', counter = 2 WHERE id = 1; +COMMIT; + +UPDATE TEST_CONCURRENT SET status = 'complete', counter = 3, note = 'done' WHERE id = 1; +COMMIT; + +-- Update different rows in same transaction then commit +UPDATE TEST_CONCURRENT SET counter = 10 WHERE id = 2; +UPDATE TEST_CONCURRENT SET counter = 20 WHERE id = 3; +COMMIT; + +-- Update same row twice in one transaction +UPDATE TEST_CONCURRENT SET status = 'reopened', counter = 4 WHERE id = 1; +UPDATE TEST_CONCURRENT SET status = 'closed', counter = 5, note = 'final' WHERE id = 1; +COMMIT; + +-- Delete and re-insert same PK in separate transactions +DELETE FROM TEST_CONCURRENT WHERE id = 2; +COMMIT; + +INSERT INTO TEST_CONCURRENT VALUES (2, 'resurrected', 1, 'back again'); +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/data-types.sql b/tests/0-inputs/data-types.sql new file mode 100644 index 00000000..65620d38 --- /dev/null +++ b/tests/0-inputs/data-types.sql @@ -0,0 +1,98 @@ +-- data-types.sql: Test various Oracle column types. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_TYPES'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_TYPES PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_TYPES ( + id NUMBER PRIMARY KEY, + col_varchar2 VARCHAR2(200), + col_char CHAR(20), + col_nvarchar2 NVARCHAR2(200), + col_number NUMBER(15,2), + col_float BINARY_FLOAT, + col_double BINARY_DOUBLE, + col_date DATE, + col_timestamp TIMESTAMP(6), + col_raw RAW(100), + col_integer INTEGER +); + +ALTER TABLE TEST_TYPES ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- DML: INSERTs with various types +INSERT INTO TEST_TYPES VALUES ( + 1, + 'hello world', + 'fixed ', + N'unicode text', + 12345.67, + 3.14, + 2.718281828459045, + TO_DATE('2025-06-15 10:30:00', 'YYYY-MM-DD HH24:MI:SS'), + TO_TIMESTAMP('2025-06-15 10:30:00.123456', 'YYYY-MM-DD HH24:MI:SS.FF6'), + HEXTORAW('DEADBEEF'), + 42 +); +INSERT INTO TEST_TYPES VALUES ( + 2, + 'second row', + 'row2 ', + N'more text', + -999.99, + -1.5, + 1.0e100, + TO_DATE('2000-01-01 00:00:00', 'YYYY-MM-DD HH24:MI:SS'), + TO_TIMESTAMP('2000-01-01 00:00:00.000000', 'YYYY-MM-DD HH24:MI:SS.FF6'), + HEXTORAW('CAFEBABE'), + 0 +); +COMMIT; + +-- DML: UPDATE various type columns +UPDATE TEST_TYPES SET + col_varchar2 = 'updated text', + col_float = 9.99, + col_date = TO_DATE('2026-01-01 12:00:00', 'YYYY-MM-DD HH24:MI:SS'), + col_raw = HEXTORAW('00FF00FF') +WHERE id = 1; +COMMIT; + +-- DML: DELETE +DELETE FROM TEST_TYPES WHERE id = 2; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/ddl-add-column.sql b/tests/0-inputs/ddl-add-column.sql new file mode 100644 index 00000000..062a3a0d --- /dev/null +++ b/tests/0-inputs/ddl-add-column.sql @@ -0,0 +1,69 @@ +-- ddl-add-column.sql: DML around an ALTER TABLE ADD COLUMN DDL change. +-- Tests OLR's ability to handle schema changes mid-stream. +-- @DDL +-- +-- Scenario: +-- 1. Create table with 3 columns, do some INSERTs +-- 2. ALTER TABLE ADD COLUMN (DDL) +-- 3. Do more DML using the new column +-- 4. Verify OLR correctly handles schema change + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_DDL'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_DDL PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_DDL ( + id NUMBER PRIMARY KEY, + name VARCHAR2(100), + val NUMBER +); + +ALTER TABLE TEST_DDL ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- Phase 1: DML with original schema (3 columns) +INSERT INTO TEST_DDL VALUES (1, 'Alice', 100); +INSERT INTO TEST_DDL VALUES (2, 'Bob', 200); +INSERT INTO TEST_DDL VALUES (3, 'Charlie', 300); +COMMIT; + +-- DDL: Add a new column +ALTER TABLE TEST_DDL ADD (email VARCHAR2(200)); + +-- Phase 2: DML with new schema (4 columns) +INSERT INTO TEST_DDL VALUES (4, 'Dave', 400, 'dave@test.com'); +UPDATE TEST_DDL SET email = 'alice@test.com' WHERE id = 1; +UPDATE TEST_DDL SET val = 250, email = 'bob@test.com' WHERE id = 2; +DELETE FROM TEST_DDL WHERE id = 3; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/interleaved-transactions.sql b/tests/0-inputs/interleaved-transactions.sql new file mode 100644 index 00000000..20683251 --- /dev/null +++ b/tests/0-inputs/interleaved-transactions.sql @@ -0,0 +1,115 @@ +-- interleaved-transactions.sql: Multiple transactions interleaved in redo log. +-- Uses autonomous transactions to create genuine redo interleaving within +-- a single sqlplus session. Tests OLR's transaction correlation and XID tracking. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_INTERLEAVE'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_INTERLEAVE PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_INTERLEAVE ( + id NUMBER PRIMARY KEY, + source VARCHAR2(20), + val NUMBER +); + +ALTER TABLE TEST_INTERLEAVE ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- Interleaved pattern: main transaction and autonomous transactions +-- create genuine redo interleaving +DECLARE + PROCEDURE auto_insert(p_id NUMBER, p_source VARCHAR2, p_val NUMBER) IS + PRAGMA AUTONOMOUS_TRANSACTION; + BEGIN + INSERT INTO TEST_INTERLEAVE VALUES (p_id, p_source, p_val); + COMMIT; + END; + + PROCEDURE auto_update(p_id NUMBER, p_val NUMBER) IS + PRAGMA AUTONOMOUS_TRANSACTION; + BEGIN + UPDATE TEST_INTERLEAVE SET val = p_val WHERE id = p_id; + COMMIT; + END; + + PROCEDURE auto_delete(p_id NUMBER) IS + PRAGMA AUTONOMOUS_TRANSACTION; + BEGIN + DELETE FROM TEST_INTERLEAVE WHERE id = p_id; + COMMIT; + END; +BEGIN + -- Main txn: insert id=1 + INSERT INTO TEST_INTERLEAVE VALUES (1, 'main', 100); + + -- Auto txn 1: insert id=2 + commit (interleaves with main) + auto_insert(2, 'auto1', 200); + + -- Main txn: insert id=3 (still uncommitted) + INSERT INTO TEST_INTERLEAVE VALUES (3, 'main', 300); + + -- Auto txn 2: insert id=4 + update id=2 + commit + auto_insert(4, 'auto2', 400); + auto_update(2, 250); + + -- Main txn: update id=1, then commit + UPDATE TEST_INTERLEAVE SET val = 150 WHERE id = 1; + COMMIT; +END; +/ + +-- Second wave: another interleaved pattern +DECLARE + PROCEDURE auto_ops IS + PRAGMA AUTONOMOUS_TRANSACTION; + BEGIN + INSERT INTO TEST_INTERLEAVE VALUES (5, 'auto3', 500); + UPDATE TEST_INTERLEAVE SET source = 'auto3-upd' WHERE id = 5; + COMMIT; + END; +BEGIN + -- Main txn: update + UPDATE TEST_INTERLEAVE SET val = 350 WHERE id = 3; + + -- Auto txn 3: insert + update in same auto txn + auto_ops; + + -- Main txn: delete + commit + DELETE FROM TEST_INTERLEAVE WHERE id = 4; + COMMIT; +END; +/ + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/large-transaction.sql b/tests/0-inputs/large-transaction.sql new file mode 100644 index 00000000..6b69a1f4 --- /dev/null +++ b/tests/0-inputs/large-transaction.sql @@ -0,0 +1,65 @@ +-- large-transaction.sql: Test large transaction with many rows in a single commit. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_BULK'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_BULK PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_BULK ( + id NUMBER PRIMARY KEY, + payload VARCHAR2(200), + val NUMBER +); + +ALTER TABLE TEST_BULK ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- Transaction 1: bulk insert 200 rows in a single commit +BEGIN + FOR i IN 1..200 LOOP + INSERT INTO TEST_BULK VALUES (i, 'row_' || LPAD(i, 4, '0'), i * 10); + END LOOP; + COMMIT; +END; +/ + +-- Transaction 2: bulk update all rows +UPDATE TEST_BULK SET val = val + 1; +COMMIT; + +-- Transaction 3: bulk delete half the rows +DELETE FROM TEST_BULK WHERE MOD(id, 2) = 0; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/lob-operations.sql b/tests/0-inputs/lob-operations.sql new file mode 100644 index 00000000..1745ec4e --- /dev/null +++ b/tests/0-inputs/lob-operations.sql @@ -0,0 +1,84 @@ +-- lob-operations.sql: Test CLOB and BLOB column handling. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_LOBS'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_LOBS PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_LOBS ( + id NUMBER PRIMARY KEY, + col_clob CLOB, + col_blob BLOB, + col_label VARCHAR2(50) +); + +ALTER TABLE TEST_LOBS ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- DML: INSERT small inline LOBs (stored in-row) +INSERT INTO TEST_LOBS VALUES (1, 'Short CLOB text', HEXTORAW('AABBCCDD'), 'small-inline'); +INSERT INTO TEST_LOBS VALUES (2, 'Another small CLOB', HEXTORAW('0102030405'), 'small-inline-2'); +COMMIT; + +-- DML: INSERT with NULL LOBs +INSERT INTO TEST_LOBS VALUES (3, NULL, NULL, 'null-lobs'); +COMMIT; + +-- DML: INSERT medium CLOB (> 4000 bytes, forces out-of-row storage) +DECLARE + v_clob CLOB; +BEGIN + v_clob := RPAD('Medium CLOB content. ', 8000, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ '); + INSERT INTO TEST_LOBS VALUES (4, v_clob, HEXTORAW(RPAD('FF', 200, 'EE')), 'medium-lob'); + COMMIT; +END; +/ + +-- DML: UPDATE CLOB value +UPDATE TEST_LOBS SET col_clob = 'Updated CLOB text', col_label = 'updated' WHERE id = 1; +COMMIT; + +-- DML: UPDATE set LOB to NULL +UPDATE TEST_LOBS SET col_clob = NULL, col_blob = NULL WHERE id = 2; +COMMIT; + +-- DML: UPDATE set NULL LOB to value +UPDATE TEST_LOBS SET col_clob = 'Was null, now has value', col_blob = HEXTORAW('DEADBEEF') WHERE id = 3; +COMMIT; + +-- DML: DELETE row with LOBs +DELETE FROM TEST_LOBS WHERE id = 1; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/long-spanning-txn.sql b/tests/0-inputs/long-spanning-txn.sql new file mode 100644 index 00000000..6c48e712 --- /dev/null +++ b/tests/0-inputs/long-spanning-txn.sql @@ -0,0 +1,77 @@ +-- long-spanning-txn.sql: Test transaction spanning multiple archive logs. +-- Verifies OLR correctly reassembles a transaction whose redo records +-- are split across archive log boundaries via forced log switches. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- @MID_SWITCH markers tell generate.sh to trigger log switches during +-- the DBMS_SESSION.SLEEP() pauses (the SQL runs in background). +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_SPANNING'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_SPANNING PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_SPANNING ( + id NUMBER PRIMARY KEY, + payload VARCHAR2(200), + val NUMBER +); + +ALTER TABLE TEST_SPANNING ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- Transaction 1: spans an archive log boundary. +-- INSERTs go to archive log N, then log switch, then more DML + COMMIT in log N+1. +INSERT INTO TEST_SPANNING VALUES (1, 'before-switch-1', 100); +INSERT INTO TEST_SPANNING VALUES (2, 'before-switch-2', 200); +INSERT INTO TEST_SPANNING VALUES (3, 'before-switch-3', 300); + +-- @MID_SWITCH +-- Pause to let generate.sh trigger a log switch while this txn is open +BEGIN DBMS_SESSION.SLEEP(15); END; +/ + +-- Continue the same (uncommitted) transaction after the log switch +INSERT INTO TEST_SPANNING VALUES (4, 'after-switch-1', 400); +INSERT INTO TEST_SPANNING VALUES (5, 'after-switch-2', 500); +UPDATE TEST_SPANNING SET val = 150 WHERE id = 1; +DELETE FROM TEST_SPANNING WHERE id = 3; +COMMIT; + +-- Transaction 2: normal (non-spanning) transaction after the gap +INSERT INTO TEST_SPANNING VALUES (6, 'post-span', 600); +UPDATE TEST_SPANNING SET payload = 'post-updated' WHERE id = 2; +DELETE FROM TEST_SPANNING WHERE id = 5; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/many-columns.sql b/tests/0-inputs/many-columns.sql new file mode 100644 index 00000000..f3fa0add --- /dev/null +++ b/tests/0-inputs/many-columns.sql @@ -0,0 +1,158 @@ +-- many-columns.sql: Test table with 50+ columns. +-- Stress tests OLR's column parsing, supplemental log handling, +-- and redo record layout with wide schema definitions. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_MANY_COLS'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_MANY_COLS PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_MANY_COLS ( + id NUMBER PRIMARY KEY, + col_01 VARCHAR2(50), + col_02 VARCHAR2(50), + col_03 VARCHAR2(50), + col_04 VARCHAR2(50), + col_05 VARCHAR2(50), + col_06 VARCHAR2(50), + col_07 VARCHAR2(50), + col_08 VARCHAR2(50), + col_09 VARCHAR2(50), + col_10 VARCHAR2(50), + col_11 NUMBER, + col_12 NUMBER, + col_13 NUMBER, + col_14 NUMBER, + col_15 NUMBER, + col_16 NUMBER, + col_17 NUMBER, + col_18 NUMBER, + col_19 NUMBER, + col_20 NUMBER, + col_21 DATE, + col_22 DATE, + col_23 DATE, + col_24 TIMESTAMP, + col_25 TIMESTAMP, + col_26 VARCHAR2(100), + col_27 VARCHAR2(100), + col_28 VARCHAR2(100), + col_29 VARCHAR2(100), + col_30 VARCHAR2(100), + col_31 NUMBER(10,2), + col_32 NUMBER(10,2), + col_33 NUMBER(10,2), + col_34 NUMBER(10,2), + col_35 NUMBER(10,2), + col_36 VARCHAR2(200), + col_37 VARCHAR2(200), + col_38 VARCHAR2(200), + col_39 VARCHAR2(200), + col_40 VARCHAR2(200), + col_41 NUMBER, + col_42 NUMBER, + col_43 NUMBER, + col_44 NUMBER, + col_45 NUMBER, + col_46 VARCHAR2(50), + col_47 VARCHAR2(50), + col_48 VARCHAR2(50), + col_49 VARCHAR2(50), + col_50 VARCHAR2(50), + col_51 NUMBER, + col_52 NUMBER, + col_53 NUMBER, + col_54 NUMBER, + col_55 NUMBER, + col_56 VARCHAR2(100), + col_57 VARCHAR2(100), + col_58 VARCHAR2(100), + col_59 VARCHAR2(100), + col_60 VARCHAR2(100) +); + +ALTER TABLE TEST_MANY_COLS ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- Insert 1: All columns populated +INSERT INTO TEST_MANY_COLS VALUES ( + 1, + 'str01', 'str02', 'str03', 'str04', 'str05', + 'str06', 'str07', 'str08', 'str09', 'str10', + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + TO_DATE('2025-01-15', 'YYYY-MM-DD'), + TO_DATE('2025-06-30', 'YYYY-MM-DD'), + TO_DATE('2025-12-31', 'YYYY-MM-DD'), + TO_TIMESTAMP('2025-01-15 10:30:00.123456', 'YYYY-MM-DD HH24:MI:SS.FF6'), + TO_TIMESTAMP('2025-06-30 23:59:59.999999', 'YYYY-MM-DD HH24:MI:SS.FF6'), + 'medium26', 'medium27', 'medium28', 'medium29', 'medium30', + 31.01, 32.02, 33.03, 34.04, 35.05, + 'long36-value', 'long37-value', 'long38-value', 'long39-value', 'long40-value', + 41, 42, 43, 44, 45, + 'str46', 'str47', 'str48', 'str49', 'str50', + 51, 52, 53, 54, 55, + 'medium56', 'medium57', 'medium58', 'medium59', 'medium60' +); +COMMIT; + +-- Insert 2: Many NULLs (sparse row) +INSERT INTO TEST_MANY_COLS (id, col_01, col_11, col_21, col_31, col_41, col_51) VALUES ( + 2, 'sparse', 99, TO_DATE('2025-03-15', 'YYYY-MM-DD'), 99.99, 99, 99 +); +COMMIT; + +-- Update 1: Change many columns at once on the full row +UPDATE TEST_MANY_COLS SET + col_01 = 'updated01', col_05 = 'updated05', col_10 = 'updated10', + col_11 = 111, col_15 = 115, col_20 = 120, + col_26 = 'updated26', col_30 = 'updated30', + col_36 = 'updated36-long-value', col_40 = 'updated40-long-value', + col_46 = 'updated46', col_50 = 'updated50', + col_56 = 'updated56', col_60 = 'updated60' +WHERE id = 1; +COMMIT; + +-- Update 2: Change a single column on the sparse row +UPDATE TEST_MANY_COLS SET col_01 = 'sparse-updated' WHERE id = 2; +COMMIT; + +-- Delete the sparse row +DELETE FROM TEST_MANY_COLS WHERE id = 2; +COMMIT; + +-- Delete the full row +DELETE FROM TEST_MANY_COLS WHERE id = 1; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/multi-table.sql b/tests/0-inputs/multi-table.sql new file mode 100644 index 00000000..556ffb8c --- /dev/null +++ b/tests/0-inputs/multi-table.sql @@ -0,0 +1,87 @@ +-- multi-table.sql: Test DML across multiple tables within and across transactions. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate tables +DECLARE + v_table_exists NUMBER; +BEGIN + FOR t IN (SELECT table_name FROM user_tables + WHERE table_name IN ('TEST_ORDERS', 'TEST_ITEMS', 'TEST_AUDIT')) LOOP + EXECUTE IMMEDIATE 'DROP TABLE ' || t.table_name || ' PURGE'; + END LOOP; +END; +/ + +CREATE TABLE TEST_ORDERS ( + order_id NUMBER PRIMARY KEY, + customer VARCHAR2(100), + order_date DATE, + status VARCHAR2(20) +); + +CREATE TABLE TEST_ITEMS ( + item_id NUMBER PRIMARY KEY, + order_id NUMBER, + product VARCHAR2(100), + qty NUMBER, + price NUMBER(10,2) +); + +CREATE TABLE TEST_AUDIT ( + audit_id NUMBER PRIMARY KEY, + action VARCHAR2(50), + detail VARCHAR2(200) +); + +ALTER TABLE TEST_ORDERS ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; +ALTER TABLE TEST_ITEMS ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; +ALTER TABLE TEST_AUDIT ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- Transaction 1: insert across all three tables in one commit +INSERT INTO TEST_ORDERS VALUES (1, 'Alice', TO_DATE('2025-01-15', 'YYYY-MM-DD'), 'NEW'); +INSERT INTO TEST_ITEMS VALUES (101, 1, 'Widget A', 2, 19.99); +INSERT INTO TEST_ITEMS VALUES (102, 1, 'Widget B', 1, 49.99); +INSERT INTO TEST_AUDIT VALUES (1, 'ORDER_CREATED', 'Order 1 for Alice'); +COMMIT; + +-- Transaction 2: second order, separate commit +INSERT INTO TEST_ORDERS VALUES (2, 'Bob', TO_DATE('2025-01-16', 'YYYY-MM-DD'), 'NEW'); +INSERT INTO TEST_ITEMS VALUES (201, 2, 'Gadget X', 5, 9.99); +COMMIT; + +-- Transaction 3: update across tables +UPDATE TEST_ORDERS SET status = 'SHIPPED' WHERE order_id = 1; +UPDATE TEST_ITEMS SET qty = 3 WHERE item_id = 101; +INSERT INTO TEST_AUDIT VALUES (2, 'ORDER_SHIPPED', 'Order 1 shipped'); +COMMIT; + +-- Transaction 4: delete from multiple tables +DELETE FROM TEST_ITEMS WHERE order_id = 2; +DELETE FROM TEST_ORDERS WHERE order_id = 2; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/null-handling.sql b/tests/0-inputs/null-handling.sql new file mode 100644 index 00000000..0f19e57d --- /dev/null +++ b/tests/0-inputs/null-handling.sql @@ -0,0 +1,71 @@ +-- null-handling.sql: Test NULL value handling in CDC output. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_NULLS'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_NULLS PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_NULLS ( + id NUMBER PRIMARY KEY, + col_a VARCHAR2(100), + col_b NUMBER, + col_c DATE +); + +ALTER TABLE TEST_NULLS ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- DML: INSERT with NULLs in different columns +INSERT INTO TEST_NULLS VALUES (1, 'has value', 100, TO_DATE('2025-01-01', 'YYYY-MM-DD')); +INSERT INTO TEST_NULLS VALUES (2, NULL, NULL, NULL); +INSERT INTO TEST_NULLS VALUES (3, 'partial', NULL, TO_DATE('2025-06-15', 'YYYY-MM-DD')); +COMMIT; + +-- DML: UPDATE value -> NULL +UPDATE TEST_NULLS SET col_a = NULL, col_b = NULL WHERE id = 1; +COMMIT; + +-- DML: UPDATE NULL -> value +UPDATE TEST_NULLS SET col_a = 'now has value', col_b = 999 WHERE id = 2; +COMMIT; + +-- DML: UPDATE NULL -> NULL (no actual change on nullable cols, but PK triggers log) +UPDATE TEST_NULLS SET col_c = NULL WHERE id = 2; +COMMIT; + +-- DML: DELETE row with NULLs +DELETE FROM TEST_NULLS WHERE id = 3; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/number-precision.sql b/tests/0-inputs/number-precision.sql new file mode 100644 index 00000000..4269ef40 --- /dev/null +++ b/tests/0-inputs/number-precision.sql @@ -0,0 +1,130 @@ +-- number-precision.sql: Test NUMBER type edge cases and precision limits. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_NUMBERS'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_NUMBERS PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_NUMBERS ( + id NUMBER PRIMARY KEY, + col_number NUMBER, + col_precise NUMBER(38,0), + col_decimal NUMBER(20,10), + col_small NUMBER(5,2), + col_integer INTEGER, + col_float BINARY_FLOAT, + col_double BINARY_DOUBLE +); + +ALTER TABLE TEST_NUMBERS ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- DML: Basic number values +INSERT INTO TEST_NUMBERS VALUES (1, 0, 0, 0, 0, 0, 0, 0); +INSERT INTO TEST_NUMBERS VALUES (2, 1, 1, 1, 1, 1, 1, 1); +INSERT INTO TEST_NUMBERS VALUES (3, -1, -1, -1, -1.00, -1, -1, -1); +COMMIT; + +-- DML: Maximum precision NUMBER(38,0) +INSERT INTO TEST_NUMBERS VALUES (4, + 99999999999999999999999999999999999999, + 99999999999999999999999999999999999999, + 9999999999.9999999999, + 999.99, + 2147483647, + 256.0, + 65536.125 +); +COMMIT; + +-- DML: Negative large numbers +INSERT INTO TEST_NUMBERS VALUES (5, + -99999999999999999999999999999999999999, + -99999999999999999999999999999999999999, + -9999999999.9999999999, + -999.99, + -2147483648, + -256.0, + -65536.125 +); +COMMIT; + +-- DML: Very small decimal values +INSERT INTO TEST_NUMBERS VALUES (6, + 0.000000000000000000000000000000000001, + 0, + 0.0000000001, + 0.01, + 0, + 0.5, + 0.0625 +); +COMMIT; + +-- DML: Fractional values with many decimal places +INSERT INTO TEST_NUMBERS VALUES (7, + 3.14159265358979323846264338327950288, + 3, + 3.1415926536, + 3.14, + 3, + 3.14159265, + 3.14159265358979323846 +); +COMMIT; + +-- DML: UPDATE to different precision values +UPDATE TEST_NUMBERS SET + col_number = 12345678901234567890, + col_precise = 12345678901234567890123456789012345678, + col_decimal = 1234567890.1234567890, + col_small = 123.45 +WHERE id = 1; +COMMIT; + +-- DML: UPDATE to zero from large +UPDATE TEST_NUMBERS SET + col_number = 0, + col_precise = 0, + col_decimal = 0, + col_small = 0 +WHERE id = 4; +COMMIT; + +-- DML: DELETE +DELETE FROM TEST_NUMBERS WHERE id = 3; +DELETE FROM TEST_NUMBERS WHERE id = 5; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/partitioned-table.sql b/tests/0-inputs/partitioned-table.sql new file mode 100644 index 00000000..796fce61 --- /dev/null +++ b/tests/0-inputs/partitioned-table.sql @@ -0,0 +1,103 @@ +-- partitioned-table.sql: Test DML on range-partitioned and list-partitioned tables. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate tables +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_RANGE_PART'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_RANGE_PART PURGE'; + END IF; + + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_LIST_PART'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_LIST_PART PURGE'; + END IF; +END; +/ + +-- Range-partitioned table by ID +CREATE TABLE TEST_RANGE_PART ( + id NUMBER PRIMARY KEY, + name VARCHAR2(100), + val NUMBER +) +PARTITION BY RANGE (id) ( + PARTITION p_low VALUES LESS THAN (100), + PARTITION p_mid VALUES LESS THAN (200), + PARTITION p_high VALUES LESS THAN (MAXVALUE) +); + +ALTER TABLE TEST_RANGE_PART ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- List-partitioned table by region +CREATE TABLE TEST_LIST_PART ( + id NUMBER PRIMARY KEY, + region VARCHAR2(20), + amount NUMBER(10,2) +) +PARTITION BY LIST (region) ( + PARTITION p_east VALUES ('EAST'), + PARTITION p_west VALUES ('WEST'), + PARTITION p_other VALUES (DEFAULT) +); + +ALTER TABLE TEST_LIST_PART ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- DML: INSERT into different range partitions +INSERT INTO TEST_RANGE_PART VALUES (10, 'Low-A', 100); +INSERT INTO TEST_RANGE_PART VALUES (50, 'Low-B', 200); +INSERT INTO TEST_RANGE_PART VALUES (150, 'Mid-A', 300); +INSERT INTO TEST_RANGE_PART VALUES (250, 'High-A', 400); +COMMIT; + +-- DML: INSERT into different list partitions +INSERT INTO TEST_LIST_PART VALUES (1, 'EAST', 1000.50); +INSERT INTO TEST_LIST_PART VALUES (2, 'WEST', 2000.75); +INSERT INTO TEST_LIST_PART VALUES (3, 'NORTH', 3000.00); +COMMIT; + +-- DML: UPDATE rows in different partitions +UPDATE TEST_RANGE_PART SET val = 150 WHERE id = 10; +UPDATE TEST_RANGE_PART SET val = 350 WHERE id = 150; +COMMIT; + +UPDATE TEST_LIST_PART SET amount = 1500.00 WHERE id = 1; +COMMIT; + +-- DML: DELETE from different partitions +DELETE FROM TEST_RANGE_PART WHERE id = 50; +DELETE FROM TEST_RANGE_PART WHERE id = 250; +COMMIT; + +DELETE FROM TEST_LIST_PART WHERE id = 3; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/rollback.sql b/tests/0-inputs/rollback.sql new file mode 100644 index 00000000..58704e14 --- /dev/null +++ b/tests/0-inputs/rollback.sql @@ -0,0 +1,75 @@ +-- rollback.sql: Test that rolled-back transactions are excluded from CDC output. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_ROLLBACK'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_ROLLBACK PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_ROLLBACK ( + id NUMBER PRIMARY KEY, + name VARCHAR2(100), + val NUMBER +); + +ALTER TABLE TEST_ROLLBACK ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- Transaction 1: committed (should appear) +INSERT INTO TEST_ROLLBACK VALUES (1, 'committed row 1', 100); +INSERT INTO TEST_ROLLBACK VALUES (2, 'committed row 2', 200); +COMMIT; + +-- Transaction 2: rolled back (should NOT appear) +INSERT INTO TEST_ROLLBACK VALUES (3, 'this will be rolled back', 300); +UPDATE TEST_ROLLBACK SET val = 999 WHERE id = 1; +ROLLBACK; + +-- Transaction 3: committed (should appear, row 1 still has val=100) +INSERT INTO TEST_ROLLBACK VALUES (4, 'after rollback', 400); +COMMIT; + +-- Transaction 4: partial rollback via savepoint +SAVEPOINT sp1; +INSERT INTO TEST_ROLLBACK VALUES (5, 'before savepoint', 500); +SAVEPOINT sp2; +INSERT INTO TEST_ROLLBACK VALUES (6, 'after savepoint - will rollback', 600); +ROLLBACK TO sp2; +INSERT INTO TEST_ROLLBACK VALUES (7, 'after partial rollback', 700); +COMMIT; + +-- Transaction 5: committed delete (should appear) +DELETE FROM TEST_ROLLBACK WHERE id = 2; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/special-chars.sql b/tests/0-inputs/special-chars.sql new file mode 100644 index 00000000..f81b6cfd --- /dev/null +++ b/tests/0-inputs/special-chars.sql @@ -0,0 +1,74 @@ +-- special-chars.sql: Test special characters in string data. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_SPECIAL'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_SPECIAL PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_SPECIAL ( + id NUMBER PRIMARY KEY, + label VARCHAR2(100), + data VARCHAR2(500) +); + +ALTER TABLE TEST_SPECIAL ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- DML: strings with single quotes (escaped as '') +INSERT INTO TEST_SPECIAL VALUES (1, 'single quote', 'it''s a test'); +INSERT INTO TEST_SPECIAL VALUES (2, 'double quote', 'she said "hello"'); +INSERT INTO TEST_SPECIAL VALUES (3, 'backslash', 'path\to\file'); +INSERT INTO TEST_SPECIAL VALUES (4, 'pipe and ampersand', 'a|b&c'); +COMMIT; + +-- DML: strings with whitespace characters +INSERT INTO TEST_SPECIAL VALUES (5, 'tab char', 'before' || CHR(9) || 'after'); +INSERT INTO TEST_SPECIAL VALUES (6, 'newline', 'line1' || CHR(10) || 'line2'); +INSERT INTO TEST_SPECIAL VALUES (7, 'cr+lf', 'line1' || CHR(13) || CHR(10) || 'line2'); +COMMIT; + +-- DML: empty string and spaces +INSERT INTO TEST_SPECIAL VALUES (8, 'spaces only', ' '); +INSERT INTO TEST_SPECIAL VALUES (9, 'leading trailing', ' padded '); +COMMIT; + +-- DML: update with special chars +UPDATE TEST_SPECIAL SET data = 'updated: it''s "new"' WHERE id = 1; +COMMIT; + +-- DML: delete +DELETE FROM TEST_SPECIAL WHERE id = 4; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/timestamp-variants.sql b/tests/0-inputs/timestamp-variants.sql new file mode 100644 index 00000000..9567e936 --- /dev/null +++ b/tests/0-inputs/timestamp-variants.sql @@ -0,0 +1,109 @@ +-- timestamp-variants.sql: Test TIMESTAMP with various precisions and DATE edge cases. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Note: TIMESTAMP WITH TIME ZONE, INTERVAL YEAR TO MONTH, and INTERVAL DAY TO SECOND +-- are not tested here because logminer2json.py doesn't yet parse TO_TIMESTAMP_TZ(), +-- TO_YMINTERVAL(), and TO_DSINTERVAL() functions. +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_TIMESTAMPS'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_TIMESTAMPS PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_TIMESTAMPS ( + id NUMBER PRIMARY KEY, + col_date DATE, + col_ts0 TIMESTAMP(0), + col_ts3 TIMESTAMP(3), + col_ts6 TIMESTAMP(6), + col_ts9 TIMESTAMP(9) +); + +ALTER TABLE TEST_TIMESTAMPS ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- DML: INSERT with various precisions +INSERT INTO TEST_TIMESTAMPS VALUES (1, + TO_DATE('2025-06-15 10:30:00', 'YYYY-MM-DD HH24:MI:SS'), + TO_TIMESTAMP('2025-06-15 10:30:00', 'YYYY-MM-DD HH24:MI:SS'), + TO_TIMESTAMP('2025-06-15 10:30:00.123', 'YYYY-MM-DD HH24:MI:SS.FF3'), + TO_TIMESTAMP('2025-06-15 10:30:00.123456', 'YYYY-MM-DD HH24:MI:SS.FF6'), + TO_TIMESTAMP('2025-06-15 10:30:00.123456789', 'YYYY-MM-DD HH24:MI:SS.FF9') +); +COMMIT; + +-- DML: INSERT midnight/epoch values +INSERT INTO TEST_TIMESTAMPS VALUES (2, + TO_DATE('2000-01-01 00:00:00', 'YYYY-MM-DD HH24:MI:SS'), + TO_TIMESTAMP('2000-01-01 00:00:00', 'YYYY-MM-DD HH24:MI:SS'), + TO_TIMESTAMP('2000-01-01 00:00:00.000', 'YYYY-MM-DD HH24:MI:SS.FF3'), + TO_TIMESTAMP('2000-01-01 00:00:00.000000', 'YYYY-MM-DD HH24:MI:SS.FF6'), + TO_TIMESTAMP('2000-01-01 00:00:00.000000000', 'YYYY-MM-DD HH24:MI:SS.FF9') +); +COMMIT; + +-- DML: INSERT end-of-day values +INSERT INTO TEST_TIMESTAMPS VALUES (3, + TO_DATE('2026-12-31 23:59:59', 'YYYY-MM-DD HH24:MI:SS'), + TO_TIMESTAMP('2026-12-31 23:59:59', 'YYYY-MM-DD HH24:MI:SS'), + TO_TIMESTAMP('2026-12-31 23:59:59.999', 'YYYY-MM-DD HH24:MI:SS.FF3'), + TO_TIMESTAMP('2026-12-31 23:59:59.999999', 'YYYY-MM-DD HH24:MI:SS.FF6'), + TO_TIMESTAMP('2026-12-31 23:59:59.999999999', 'YYYY-MM-DD HH24:MI:SS.FF9') +); +COMMIT; + +-- DML: INSERT with NULL timestamps +INSERT INTO TEST_TIMESTAMPS VALUES (4, NULL, NULL, NULL, NULL, NULL); +COMMIT; + +-- DML: UPDATE timestamp values +UPDATE TEST_TIMESTAMPS SET + col_date = TO_DATE('2026-03-01 08:00:00', 'YYYY-MM-DD HH24:MI:SS'), + col_ts3 = TO_TIMESTAMP('2026-03-01 08:00:00.500', 'YYYY-MM-DD HH24:MI:SS.FF3'), + col_ts6 = TO_TIMESTAMP('2026-03-01 08:00:00.654321', 'YYYY-MM-DD HH24:MI:SS.FF6'), + col_ts9 = TO_TIMESTAMP('2026-03-01 08:00:00.987654321', 'YYYY-MM-DD HH24:MI:SS.FF9') +WHERE id = 1; +COMMIT; + +-- DML: UPDATE NULL to value +UPDATE TEST_TIMESTAMPS SET + col_date = TO_DATE('2025-01-15 12:00:00', 'YYYY-MM-DD HH24:MI:SS'), + col_ts6 = TO_TIMESTAMP('2025-01-15 12:00:00.000001', 'YYYY-MM-DD HH24:MI:SS.FF6') +WHERE id = 4; +COMMIT; + +-- DML: DELETE +DELETE FROM TEST_TIMESTAMPS WHERE id = 2; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/0-inputs/wide-rows.sql b/tests/0-inputs/wide-rows.sql new file mode 100644 index 00000000..12200b75 --- /dev/null +++ b/tests/0-inputs/wide-rows.sql @@ -0,0 +1,75 @@ +-- wide-rows.sql: VARCHAR2(4000) max-length values and wide rows. +-- Tests block-spanning redo records with large column values. +-- Run as PDB user (e.g., olr_test/olr_test@//localhost:1521/FREEPDB1) +-- +-- Outputs: FIXTURE_SCN_START: and FIXTURE_SCN_END: + +SET SERVEROUTPUT ON +SET FEEDBACK OFF +SET ECHO OFF + +-- Setup: drop and recreate table +DECLARE + v_table_exists NUMBER; +BEGIN + SELECT COUNT(*) INTO v_table_exists + FROM user_tables WHERE table_name = 'TEST_WIDE'; + IF v_table_exists > 0 THEN + EXECUTE IMMEDIATE 'DROP TABLE TEST_WIDE PURGE'; + END IF; +END; +/ + +CREATE TABLE TEST_WIDE ( + id NUMBER PRIMARY KEY, + col_short VARCHAR2(10), + col_medium VARCHAR2(200), + col_long1 VARCHAR2(4000), + col_long2 VARCHAR2(4000) +); + +ALTER TABLE TEST_WIDE ADD SUPPLEMENTAL LOG DATA (ALL) COLUMNS; + +-- Record start SCN +DECLARE + v_start_scn NUMBER; +BEGIN + SELECT current_scn INTO v_start_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || v_start_scn); +END; +/ + +-- INSERT: max-length columns (8000+ bytes per row across two VARCHAR2(4000)) +INSERT INTO TEST_WIDE VALUES (1, 'short', RPAD('M', 200, 'M'), RPAD('A', 4000, 'A'), RPAD('B', 4000, 'B')); +COMMIT; + +-- INSERT: one long column, other NULL +INSERT INTO TEST_WIDE VALUES (2, 'half', NULL, RPAD('X', 4000, 'X'), NULL); +COMMIT; + +-- UPDATE: change both long columns +UPDATE TEST_WIDE SET col_long1 = RPAD('C', 4000, 'C'), col_long2 = RPAD('D', 4000, 'D') WHERE id = 1; +COMMIT; + +-- UPDATE: set long column to short value +UPDATE TEST_WIDE SET col_long1 = 'now short' WHERE id = 2; +COMMIT; + +-- UPDATE: set short value to max-length +UPDATE TEST_WIDE SET col_long2 = RPAD('Z', 4000, 'Z') WHERE id = 2; +COMMIT; + +-- DELETE: row with max-length columns +DELETE FROM TEST_WIDE WHERE id = 1; +COMMIT; + +-- Record end SCN +DECLARE + v_end_scn NUMBER; +BEGIN + SELECT current_scn INTO v_end_scn FROM v$database; + DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || v_end_scn); +END; +/ + +EXIT diff --git a/tests/1-environments/free-23/Makefile b/tests/1-environments/free-23/Makefile new file mode 100644 index 00000000..33adfa8b --- /dev/null +++ b/tests/1-environments/free-23/Makefile @@ -0,0 +1,24 @@ +ORACLE_TARGET := free-23 +TESTS_DIR := $(abspath ../..) +SCRIPTS_DIR := $(TESTS_DIR)/scripts +SCENARIOS := $(wildcard $(TESTS_DIR)/0-inputs/*.sql) +SCENARIO ?= + +.PHONY: up down test-sql + +up: + docker compose up -d --wait + +down: + docker compose down + +test-sql: +ifdef SCENARIO + ORACLE_TARGET=$(ORACLE_TARGET) $(SCRIPTS_DIR)/generate.sh $(SCENARIO) +else + @for sql in $(SCENARIOS); do \ + name=$$(basename "$$sql" .sql); \ + echo "=== Generating: $$name ==="; \ + ORACLE_TARGET=$(ORACLE_TARGET) $(SCRIPTS_DIR)/generate.sh "$$name" || exit 1; \ + done +endif diff --git a/tests/1-environments/free-23/docker-compose.yaml b/tests/1-environments/free-23/docker-compose.yaml new file mode 100644 index 00000000..4d61dbcd --- /dev/null +++ b/tests/1-environments/free-23/docker-compose.yaml @@ -0,0 +1,24 @@ +services: + olr: + image: olr-dev:${OLR_IMAGE_TAG:-latest} + entrypoint: [] + command: ["sleep", "infinity"] + volumes: + - ../../..:/opt/OpenLogReplicator-local + + oracle: + image: gvenzl/oracle-free:23-slim-faststart + container_name: oracle + ports: + - "1521:1521" + environment: + ORACLE_PASSWORD: oracle + APP_USER: olr_test + APP_USER_PASSWORD: olr_test + volumes: + - ./oracle-init:/container-entrypoint-initdb.d + healthcheck: + test: ["CMD", "healthcheck.sh"] + interval: 10s + timeout: 5s + retries: 30 diff --git a/tests/1-environments/free-23/oracle-init/01-setup.sh b/tests/1-environments/free-23/oracle-init/01-setup.sh new file mode 100755 index 00000000..63711816 --- /dev/null +++ b/tests/1-environments/free-23/oracle-init/01-setup.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Enable archivelog mode and supplemental logging for OLR testing. +# Runs as part of gvenzl/oracle-free container initialization. + +sqlplus -S / as sysdba <<'SQL' +SHUTDOWN IMMEDIATE; +STARTUP MOUNT; +ALTER DATABASE ARCHIVELOG; +ALTER DATABASE OPEN; +ALTER DATABASE ADD SUPPLEMENTAL LOG DATA; +ALTER SYSTEM SET db_recovery_file_dest_size=10G; + +-- Grant PDB user access to v$database for SCN queries in test scenarios +ALTER SESSION SET CONTAINER=FREEPDB1; +GRANT SELECT ON SYS.V_$DATABASE TO olr_test; +SQL diff --git a/tests/1-environments/xe-21/Makefile b/tests/1-environments/xe-21/Makefile new file mode 100644 index 00000000..42b35445 --- /dev/null +++ b/tests/1-environments/xe-21/Makefile @@ -0,0 +1,27 @@ +ORACLE_TARGET := xe-21 +TESTS_DIR := $(abspath ../..) +SCRIPTS_DIR := $(TESTS_DIR)/scripts +SCENARIOS := $(wildcard $(TESTS_DIR)/0-inputs/*.sql) +SCENARIO ?= + +export DB_CONN := olr_test/olr_test@//localhost:1521/XEPDB1 +export PDB_NAME := XEPDB1 + +.PHONY: up down test-sql + +up: + docker compose up -d --wait + +down: + docker compose down + +test-sql: +ifdef SCENARIO + ORACLE_TARGET=$(ORACLE_TARGET) $(SCRIPTS_DIR)/generate.sh $(SCENARIO) +else + @for sql in $(SCENARIOS); do \ + name=$$(basename "$$sql" .sql); \ + echo "=== Generating: $$name ==="; \ + ORACLE_TARGET=$(ORACLE_TARGET) $(SCRIPTS_DIR)/generate.sh "$$name" || exit 1; \ + done +endif diff --git a/tests/1-environments/xe-21/docker-compose.yaml b/tests/1-environments/xe-21/docker-compose.yaml new file mode 100644 index 00000000..b40cd25a --- /dev/null +++ b/tests/1-environments/xe-21/docker-compose.yaml @@ -0,0 +1,24 @@ +services: + olr: + image: olr-dev:${OLR_IMAGE_TAG:-latest} + entrypoint: [] + command: ["sleep", "infinity"] + volumes: + - ../../..:/opt/OpenLogReplicator-local + + oracle: + image: gvenzl/oracle-xe:21.3.0-slim-faststart + container_name: oracle + ports: + - "1521:1521" + environment: + ORACLE_PASSWORD: oracle + APP_USER: olr_test + APP_USER_PASSWORD: olr_test + volumes: + - ./oracle-init:/container-entrypoint-initdb.d + healthcheck: + test: ["CMD", "healthcheck.sh"] + interval: 10s + timeout: 5s + retries: 30 diff --git a/tests/1-environments/xe-21/oracle-init/01-setup.sh b/tests/1-environments/xe-21/oracle-init/01-setup.sh new file mode 100644 index 00000000..5370e789 --- /dev/null +++ b/tests/1-environments/xe-21/oracle-init/01-setup.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Enable archivelog mode and supplemental logging for OLR testing. +# Runs as part of gvenzl/oracle-xe container initialization. + +sqlplus -S / as sysdba <<'SQL' +SHUTDOWN IMMEDIATE; +STARTUP MOUNT; +ALTER DATABASE ARCHIVELOG; +ALTER DATABASE OPEN; +ALTER DATABASE ADD SUPPLEMENTAL LOG DATA; +ALTER SYSTEM SET db_recovery_file_dest_size=10G; + +-- Grant PDB user access to v$database for SCN queries in test scenarios +ALTER SESSION SET CONTAINER=XEPDB1; +GRANT SELECT ON SYS.V_$DATABASE TO olr_test; +SQL diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 00000000..a2a360c4 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,25 @@ +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/releases/download/v1.15.2/googletest-1.15.2.tar.gz + URL_HASH SHA256=7b42b4d6ed48810c5362c265a17faebe90dc2373c885e5216439d37927f02926 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +add_executable(olr_tests + test_pipeline.cpp +) + +target_compile_definitions(olr_tests PRIVATE + OLR_BINARY_PATH="$" + OLR_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) + +target_link_libraries(olr_tests PRIVATE GTest::gtest_main) +add_dependencies(olr_tests OpenLogReplicator) + +include(GoogleTest) +gtest_discover_tests(olr_tests DISCOVERY_MODE PRE_TEST) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..9ed453ed --- /dev/null +++ b/tests/README.md @@ -0,0 +1,193 @@ +# OpenLogReplicator Test Framework + +Automated regression testing for OpenLogReplicator. Each test runs OLR in batch +mode against captured Oracle redo logs and compares JSON output against golden +files validated by Oracle LogMiner. + +## Prerequisites + +- Docker with Compose v2 +- Python 3.6+ (stdlib only, no pip dependencies) +- Bash + +No C++ toolchain needed on the host — OLR is built inside Docker. + +## Quick Start + +```bash +# Build OLR Docker image (includes binary + gtest) +make build + +# Start OLR dev container + Oracle (~30s with slim-faststart image) +make up + +# Generate all fixtures (validates each against LogMiner) +make testdata + +# Run regression tests +make test + +# Generate just one fixture +make testdata SCENARIO=basic-crud + +# Cleanup +make down +``` + +## How It Works + +### Build (`make build`) + +Builds OLR inside a Docker image (`olr-dev`) using `Dockerfile.dev` at the +project root via `docker compose build olr`. The image includes all optional +dependencies (Oracle client, Kafka, Protobuf, Prometheus) with ccache for +fast incremental rebuilds. Google Test is auto-fetched via CMake FetchContent. + +### Fixture Generation (`make testdata`) + +The `scripts/generate.sh` script runs 7 stages per scenario: + +| Stage | Action | +|-------|--------| +| 0 | (DDL only) Build LogMiner dictionary into redo logs | +| 1 | Run SQL scenario against Oracle, capture start/end SCN | +| 2 | Force log switches, copy archived redo logs out of container | +| 3 | Generate schema checkpoint via `gencfg.sql` | +| 4 | Run LogMiner, convert output to JSON | +| 5 | Run OLR in batch mode via `docker run` against captured redo logs | +| 6 | Compare OLR output vs LogMiner — **fail if mismatch** | +| 7 | Save OLR output as golden file | + +### Regression Tests (`make test`) + +Runs `ctest` inside the `olr-dev` Docker image with fixture directories mounted. +The C++ test runner (`test_pipeline.cpp`) auto-discovers fixtures from +`3-expected/*/output.json`, builds a batch-mode config for each, runs OLR, +and compares output line-by-line against the golden file. + +No Oracle instance is needed to run tests — only the pre-generated fixtures. + +## Directory Structure + +``` +Makefile # Build, run, test targets +Dockerfile.dev # Builds OLR + gtest (full dev image) +docker-compose.yaml # olr (dev container) + oracle (test DB) +scripts/run.sh # Docker entrypoint script +tests/ + CMakeLists.txt # gtest build config + test_pipeline.cpp # Parameterized gtest runner + scripts/ + generate.sh # Generate + validate one fixture + compare.py # OLR vs LogMiner comparison + logminer2json.py # LogMiner spool → JSON converter + oracle-init/ + 01-setup.sh # Enables archivelog + supplemental logging + 0-inputs/ # SQL scenarios (committed) + basic-crud.sql + data-types.sql + ... + 1-schema/ # Schema checkpoints (gitignored) + 2-redo/ # Redo log files (gitignored) + 3-expected/ # Golden files (gitignored) +``` + +Only `0-inputs/`, `scripts/`, and build files are committed to git. The `1-schema/`, `2-redo/`, and `3-expected/` directories are generated +and distributed as CI artifacts. + +## Writing New Scenarios + +Create a SQL file in `0-inputs/` that: + +1. Creates test table(s) with supplemental logging +2. Records start SCN via `DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_START: ' || scn)` +3. Performs DML operations with explicit COMMITs +4. Records end SCN via `DBMS_OUTPUT.PUT_LINE('FIXTURE_SCN_END: ' || scn)` +5. Ends with `EXIT` + +See `0-inputs/basic-crud.sql` for the template. + +**Note:** Log switches are handled by `generate.sh` — don't run +`ALTER SYSTEM SWITCH LOGFILE` from the scenario SQL. + +### DDL Scenarios + +For scenarios with DDL (ALTER TABLE, etc.), add `-- @DDL` at the top. +This switches LogMiner to `DICT_FROM_REDO_LOGS` + `DDL_DICT_TRACKING` +so it can track schema changes inline. + +See `0-inputs/ddl-add-column.sql` for an example. + +### Long-Spanning Transactions + +For transactions that should span multiple archive logs, add `-- @MID_SWITCH` +markers in the SQL where log switches should occur. The SQL should use +`DBMS_SESSION.SLEEP()` at those points to allow time for the switch. + +See `0-inputs/long-spanning-txn.sql` for an example. + +## Comparison Details + +The comparison tool (`scripts/compare.py`) handles: + +- **Content-based matching**: pairs records by operation type, table, and column + values rather than strict ordering (LogMiner orders by redo SCN, OLR by + commit SCN) +- **Type tolerance**: `"100"` matches `100`, float precision differences allowed +- **Date/timestamp conversion**: Oracle format strings vs epoch seconds +- **LOB merging**: Oracle splits LOB writes into INSERT(EMPTY_CLOB) + UPDATE; + these are merged to match OLR's coalesced output +- **Supplemental log columns**: OLR includes all columns via supplemental + logging; extra columns beyond what LogMiner shows are allowed + +## CI Workflows + +### `generate-fixtures.yaml` + +Runs weekly (or manually via `workflow_dispatch`). Starts Oracle, generates all +fixtures with LogMiner validation, uploads as artifact (90-day retention). + +### `run-tests.yaml` + +Runs on push/PR to master. Downloads the latest fixture artifact and runs +`ctest` inside the `olr-dev` Docker image. **Fails hard** if no artifact exists — +run `generate-fixtures` first. + +## Makefile Targets + +| Target | Description | +|--------|-------------| +| `build` | Build OLR Docker image with tests | +| `up` | Start OLR dev container + Oracle container | +| `down` | Stop all containers | +| `test` | Run regression tests via `ctest` (inside Docker) | +| `testdata` | Generate all fixtures (or one with `SCENARIO=name`) | +| `clean` | Remove generated fixture data | + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ORACLE_CONTAINER` | `oracle` | Docker container name for Oracle | +| `ORACLE_PASSWORD` | `oracle` | SYS/SYSTEM password | +| `DB_CONN` | `olr_test/olr_test@//localhost:1521/FREEPDB1` | Test user connect string | +| `SCHEMA_OWNER` | `OLR_TEST` | Schema owner for LogMiner filter | +| `PDB_NAME` | `FREEPDB1` | PDB name for schema generation | + +## Troubleshooting + +If fixture generation fails at comparison, the working directory is preserved: + +```bash +# LogMiner parsed output +cat tests/.work/basic-crud_XXXXXX/logminer.json + +# OLR raw output +cat tests/.work/basic-crud_XXXXXX/olr_output.json + +# OLR log (includes redo parsing details) +cat tests/.work/basic-crud_XXXXXX/olr_stdout.log + +# Generated OLR config +cat tests/.work/basic-crud_XXXXXX/olr_config.json +``` diff --git a/tests/scripts/compare.py b/tests/scripts/compare.py new file mode 100644 index 00000000..74f28c8b --- /dev/null +++ b/tests/scripts/compare.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Compare normalized LogMiner output vs OLR JSON output. + +Usage: compare.py + +Exits 0 on match, 1 on mismatch with diff report. + +Comparison strategy: +- Parse OLR JSON lines, skip begin/commit/checkpoint messages +- Map OLR ops: c→INSERT, u→UPDATE, d→DELETE +- Match operations by order within each transaction +- Compare table name and column values (type-aware: "100" == 100) +""" + +import json +import re +import sys +from datetime import datetime, timezone + +OLR_OP_MAP = {'c': 'INSERT', 'u': 'UPDATE', 'd': 'DELETE'} + +# Oracle date/timestamp patterns from LogMiner +ORACLE_TIMESTAMP_RE = re.compile( + r'^(\d{2})-([A-Z]{3})-(\d{2,4})\s+(\d{1,2})\.(\d{2})\.(\d{2})(?:\.(\d+))?\s*(AM|PM)$', + re.IGNORECASE +) + +ORACLE_DATE_FORMATS = [ + '%d-%b-%y', # DD-MON-RR: 01-JAN-25 + '%d-%b-%Y', # DD-MON-RRRR: 01-JAN-2025 + '%Y-%m-%d %H:%M:%S', # YYYY-MM-DD HH24:MI:SS + '%d-%b-%y %H.%M.%S', # DD-MON-RR HH.MI.SS (Oracle default with time) +] + + +def normalize_value(v): + """Normalize a value to string for comparison. None stays None.""" + if v is None: + return None + return str(v) + + +def normalize_columns(d): + """Normalize a dict of column->value to column->string.""" + if not d or not isinstance(d, dict): + return {} + return {k: normalize_value(v) for k, v in d.items()} + + +def parse_logminer_json(path): + """Parse logminer2json.py output. One JSON object per line.""" + records = [] + with open(path) as f: + for line in f: + line = line.strip() + if not line: + continue + obj = json.loads(line) + records.append({ + 'op': obj['op'], + 'owner': obj.get('owner', ''), + 'table': obj.get('table', ''), + 'xid': obj.get('xid', ''), + 'scn': obj.get('scn', ''), + 'before': normalize_columns(obj.get('before')), + 'after': normalize_columns(obj.get('after')), + }) + return records + + +def parse_olr_json(path): + """Parse OLR JSON output. One JSON object per line. + Each line: {"scn":..., "xid":..., "payload":[{...}]} + Skip begin/commit/checkpoint messages.""" + records = [] + with open(path) as f: + for line in f: + line = line.strip() + if not line: + continue + obj = json.loads(line) + payload = obj.get('payload', []) + xid = obj.get('xid', '') + + for entry in payload: + op = entry.get('op', '') + if op not in OLR_OP_MAP: + continue + + schema = entry.get('schema', {}) + owner = schema.get('owner', '') + table = schema.get('table', '') + + before = normalize_columns(entry.get('before')) + after = normalize_columns(entry.get('after')) + + records.append({ + 'op': OLR_OP_MAP[op], + 'owner': owner, + 'table': table, + 'xid': xid, + 'scn': str(obj.get('c_scn', '')), + 'before': before, + 'after': after, + }) + return records + + +def try_parse_oracle_datetime(s): + """Try to parse an Oracle date/timestamp string to epoch seconds (UTC). + + Handles: + - DATE: '15-JUN-25' (date only, midnight) + - TIMESTAMP: '15-JUN-25 10.30.00.123456 AM' (full timestamp) + - Various date formats + + Returns (epoch_seconds, is_date_only) or (None, None) on failure. + """ + s = s.strip() + + # Try TIMESTAMP format: DD-MON-RR HH.MI.SS[.FF] AM/PM + m = ORACLE_TIMESTAMP_RE.match(s) + if m: + day, mon, year, hour, minute, sec, frac, ampm = m.groups() + hour = int(hour) + if ampm.upper() == 'PM' and hour != 12: + hour += 12 + elif ampm.upper() == 'AM' and hour == 12: + hour = 0 + try: + dt = datetime.strptime(f"{day}-{mon}-{year}", '%d-%b-%y') + dt = dt.replace(hour=hour, minute=int(minute), second=int(sec), + tzinfo=timezone.utc) + epoch = dt.timestamp() + if frac: + epoch += int(frac) / (10 ** len(frac)) + # Round half-up (not banker's rounding) to match OLR behavior + return int(epoch + 0.5), False + except ValueError: + pass + + # Try date-only formats + for fmt in ORACLE_DATE_FORMATS: + try: + dt = datetime.strptime(s, fmt) + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp()), True + except ValueError: + continue + + return None, None + + +def values_match(lm_val, olr_val): + """Compare two normalized values with type awareness.""" + if lm_val is None and olr_val is None: + return True + if lm_val is None or olr_val is None: + return False + # Direct string match + if lm_val == olr_val: + return True + # Try numeric comparison with tolerance for float precision differences + # (e.g., BINARY_FLOAT: LogMiner='3.1400001E+000', OLR='3.14') + try: + lm_f, olr_f = float(lm_val), float(olr_val) + if lm_f == olr_f: + return True + # Relative tolerance for IEEE 754 float/double representation differences + if lm_f != 0 and abs(lm_f - olr_f) / abs(lm_f) < 1e-6: + return True + if olr_f != 0 and abs(lm_f - olr_f) / abs(olr_f) < 1e-6: + return True + except (ValueError, TypeError): + pass + # Try date/timestamp comparison + lm_epoch, lm_date_only = try_parse_oracle_datetime(lm_val) + if lm_epoch is not None: + try: + olr_epoch = int(olr_val) + if lm_date_only: + # LogMiner DATE format truncates time — compare date portion only + lm_date = datetime.fromtimestamp(lm_epoch, tz=timezone.utc).date() + olr_date = datetime.fromtimestamp(olr_epoch, tz=timezone.utc).date() + if lm_date == olr_date: + return True + else: + # Full timestamp comparison (exact after rounding fractional seconds) + if lm_epoch == olr_epoch: + return True + except (ValueError, TypeError): + pass + olr_epoch, olr_date_only = try_parse_oracle_datetime(olr_val) + if olr_epoch is not None: + try: + lm_epoch_int = int(lm_val) + if olr_date_only: + lm_date = datetime.fromtimestamp(lm_epoch_int, tz=timezone.utc).date() + olr_date = datetime.fromtimestamp(olr_epoch, tz=timezone.utc).date() + if lm_date == olr_date: + return True + else: + if olr_epoch == lm_epoch_int: + return True + except (ValueError, TypeError): + pass + # Whitespace normalization: LogMiner extraction replaces CR/LF with spaces, + # OLR preserves the actual characters. Normalize and retry. + lm_ws = lm_val.replace('\r\n', ' ').replace('\n', ' ').replace('\r', '') + olr_ws = olr_val.replace('\r\n', ' ').replace('\n', ' ').replace('\r', '') + if lm_ws == olr_ws: + return True + return False + + +def columns_match(lm_cols, olr_cols, op=None, section=None): + """Compare two column dicts. + + For UPDATE 'after': OLR may include supplemental log columns not in LogMiner's + SQL_REDO SET clause — extra OLR columns are not treated as mismatches. + For other cases: OLR may omit unchanged columns — missing OLR columns are skipped. + """ + diffs = [] + all_keys = set(lm_cols.keys()) | set(olr_cols.keys()) + for key in sorted(all_keys): + lm_val = lm_cols.get(key) + olr_val = olr_cols.get(key) + if key not in olr_cols: + # OLR may omit unchanged columns in before/after — skip + continue + if key not in lm_cols: + # OLR may have columns LogMiner doesn't: supplemental logging adds + # all columns on UPDATE, and LOB data too large for SQL_REDO is + # absent from LogMiner output — skip in both cases + continue + if not values_match(lm_val, olr_val): + diffs.append(f" column {key}: LogMiner={lm_val!r}, OLR={olr_val!r}") + return diffs + + +def has_empty_lobs(after): + """Check if any column value is EMPTY_CLOB() or EMPTY_BLOB().""" + if not after: + return False + return any(v in ('EMPTY_CLOB()', 'EMPTY_BLOB()') for v in after.values() if v) + + +def normalize_lob_operations(records): + """Merge LOB-related record sequences in LogMiner output. + + Oracle splits LOB writes into multiple redo records: + A) INSERT with EMPTY_CLOB()/EMPTY_BLOB() + UPDATE with actual values + B) UPDATE with EMPTY_CLOB()/EMPTY_BLOB() + UPDATE with actual values + C) UPDATE(non-LOB cols) + UPDATE(LOB cols) at same SCN (single SQL split) + + Merges these into single records to match OLR's coalesced output. + After merging, remaining EMPTY_CLOB()/EMPTY_BLOB() values (data too large + for LogMiner SQL_REDO) are removed from the record. + """ + result = [] + i = 0 + while i < len(records): + rec = { + 'op': records[i]['op'], + 'owner': records[i]['owner'], + 'table': records[i]['table'], + 'xid': records[i]['xid'], + 'scn': records[i].get('scn', ''), + 'before': dict(records[i].get('before', {})), + 'after': dict(records[i].get('after', {})), + } + + while i + 1 < len(records): + nxt = records[i + 1] + if nxt['op'] != 'UPDATE' or nxt['xid'] != rec['xid'] or nxt['table'] != rec['table']: + break + + # Pattern A/B: current has EMPTY_CLOB/EMPTY_BLOB → next fills them + if has_empty_lobs(rec['after']): + for col, val in nxt.get('after', {}).items(): + rec['after'][col] = val + i += 1 + continue + + # Pattern C: consecutive UPDATEs at same SCN (LOB column split) + # Only merge if after dicts have no overlapping keys — Oracle splits + # a single UPDATE into non-LOB + LOB parts with disjoint columns. + # Overlapping keys means two separate UPDATE statements. + nxt_after = nxt.get('after', {}) + if (rec['op'] == 'UPDATE' and rec.get('scn') and rec['scn'] == nxt.get('scn', '') + and not (set(rec['after']) & set(nxt_after))): + for col, val in nxt_after.items(): + rec['after'][col] = val + i += 1 + continue + + break + + # Remove EMPTY_CLOB()/EMPTY_BLOB() values that weren't filled + # (LogMiner couldn't capture the data — too large for SQL_REDO) + rec['after'] = {k: v for k, v in rec['after'].items() + if v not in ('EMPTY_CLOB()', 'EMPTY_BLOB()')} + + result.append(rec) + i += 1 + return result + + +def match_score(lm, olr): + """Score how well a LogMiner record matches an OLR record. + + Returns (match_count, mismatch_count) based on common column values. + A good match has high match_count and zero mismatch_count. + """ + if lm['op'] != olr['op'] or lm['table'] != olr['table']: + return (-1, 0) + + matches = 0 + mismatches = 0 + # Check identifying section: after for INSERT, before for DELETE/UPDATE + for section in ('after', 'before'): + lm_cols = lm.get(section, {}) + olr_cols = olr.get(section, {}) + common_keys = set(lm_cols.keys()) & set(olr_cols.keys()) + for key in common_keys: + if values_match(lm_cols.get(key), olr_cols.get(key)): + matches += 1 + else: + mismatches += 1 + return (matches, mismatches) + + +def compare(lm_records, olr_records): + """Compare LogMiner vs OLR records using content-based matching. + + Uses greedy best-match to pair records regardless of ordering differences + (LogMiner orders by redo SCN, OLR orders by commit SCN). + Returns list of diff strings. + """ + diffs = [] + + if len(lm_records) != len(olr_records): + diffs.append( + f"Record count mismatch: LogMiner={len(lm_records)}, OLR={len(olr_records)}" + ) + + # Build match candidates: for each LM record, find best OLR match + used_olr = set() + pairs = [] # (lm_idx, olr_idx) + + for i, lm in enumerate(lm_records): + best_j = None + best_matches = -1 + best_mismatches = float('inf') + + for j, olr in enumerate(olr_records): + if j in used_olr: + continue + m, mm = match_score(lm, olr) + if m < 0: + continue + # Prefer: fewer mismatches, then more matches + if (mm < best_mismatches) or (mm == best_mismatches and m > best_matches): + best_j = j + best_matches = m + best_mismatches = mm + + if best_j is not None: + used_olr.add(best_j) + pairs.append((i, best_j)) + else: + diffs.append( + f"LogMiner record #{i+1} ({lm['op']} {lm['table']}): " + f"no matching OLR record found" + ) + + # Report unmatched OLR records + for j in range(len(olr_records)): + if j not in used_olr: + olr = olr_records[j] + diffs.append( + f"OLR record #{j+1} ({olr['op']} {olr['table']}): " + f"no matching LogMiner record found" + ) + + # Compare matched pairs + for lm_idx, olr_idx in pairs: + lm = lm_records[lm_idx] + olr = olr_records[olr_idx] + + if lm['op'] in ('INSERT', 'UPDATE'): + col_diffs = columns_match(lm.get('after', {}), olr.get('after', {}), + op=lm['op'], section='after') + if col_diffs: + diffs.append(f"Record (LM#{lm_idx+1}\u2194OLR#{olr_idx+1}) " + f"({lm['op']}) 'after' column diffs:") + diffs.extend(col_diffs) + + if lm['op'] in ('UPDATE', 'DELETE'): + col_diffs = columns_match(lm.get('before', {}), olr.get('before', {}), + op=lm['op'], section='before') + if col_diffs: + diffs.append(f"Record (LM#{lm_idx+1}\u2194OLR#{olr_idx+1}) " + f"({lm['op']}) 'before' column diffs:") + diffs.extend(col_diffs) + + return diffs + + +def main(): + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(2) + + logminer_path = sys.argv[1] + olr_path = sys.argv[2] + + lm_records = parse_logminer_json(logminer_path) + olr_records = parse_olr_json(olr_path) + + lm_records = normalize_lob_operations(lm_records) + + diffs = compare(lm_records, olr_records) + + if diffs: + print("MISMATCH: LogMiner vs OLR output differs:") + for d in diffs: + print(d) + print(f"\nLogMiner records: {len(lm_records)}") + print(f"OLR records: {len(olr_records)}") + sys.exit(1) + else: + print(f"MATCH: {len(lm_records)} records verified") + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/tests/scripts/generate.sh b/tests/scripts/generate.sh new file mode 100755 index 00000000..bd081609 --- /dev/null +++ b/tests/scripts/generate.sh @@ -0,0 +1,542 @@ +#!/usr/bin/env bash +# generate.sh — Generate + validate one OLR regression test fixture. +# +# Usage: ./generate.sh +# Example: ./generate.sh basic-crud +# +# Runs SQL against a local Oracle Free container (gvenzl/oracle-free), +# captures redo logs, generates schema, runs LogMiner + OLR, and compares. +# +# Prerequisites: +# - Containers running: make -C tests/1-environments/$ORACLE_TARGET up +# +# Environment variables: +# ORACLE_TARGET — Oracle environment name (default: free-23) +# ORACLE_CONTAINER — Docker container name (default: oracle) +# ORACLE_PASSWORD — SYS/SYSTEM password (default: oracle) +# DB_CONN — sqlplus connect string for test user +# (default: olr_test/olr_test@//localhost:1521/FREEPDB1) +# SCHEMA_OWNER — Schema owner for LogMiner filter (default: OLR_TEST) +# PDB_NAME — PDB service name (default: FREEPDB1) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TESTS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_ROOT="$(cd "$TESTS_DIR/.." && pwd)" + +# Oracle target environment (default: free-23) +ORACLE_TARGET="${ORACLE_TARGET:-free-23}" +ENV_DIR="$TESTS_DIR/1-environments/$ORACLE_TARGET" +if [[ ! -d "$ENV_DIR" ]]; then + echo "ERROR: Environment directory not found: $ENV_DIR" >&2 + exit 1 +fi + +# Defaults +ORACLE_CONTAINER="${ORACLE_CONTAINER:-oracle}" +ORACLE_PASSWORD="${ORACLE_PASSWORD:-oracle}" +DB_CONN="${DB_CONN:-olr_test/olr_test@//localhost:1521/FREEPDB1}" +SCHEMA_OWNER="${SCHEMA_OWNER:-OLR_TEST}" +PDB_NAME="${PDB_NAME:-FREEPDB1}" +COMPOSE="docker compose -f $ENV_DIR/docker-compose.yaml" + +# Container path prefix — tests/ is mounted at this path in the olr container +CONTAINER_TESTS=/opt/OpenLogReplicator-local/tests + +SCENARIO="${1:?Usage: $0 }" +SCENARIO_SQL="$TESTS_DIR/0-inputs/${SCENARIO}.sql" + +if [[ ! -f "$SCENARIO_SQL" ]]; then + echo "ERROR: Scenario file not found: $SCENARIO_SQL" >&2 + echo "Available scenarios:" >&2 + ls "$TESTS_DIR/0-inputs/"*.sql 2>/dev/null | sed 's/.*\// /' | sed 's/\.sql$//' >&2 + exit 1 +fi + +# Working directory for this run (under tests/.work/ so it's visible inside the olr container) +mkdir -p "$TESTS_DIR/.work" +WORK_DIR=$(mktemp -d "$TESTS_DIR/.work/${ORACLE_TARGET}_${SCENARIO}_XXXXXX") +trap 'rm -rf "$WORK_DIR"' EXIT + +# Helper: run sqlplus as sysdba inside the Oracle container +run_sysdba() { + local sql_file="$1" + docker exec "$ORACLE_CONTAINER" sqlplus -S / as sysdba @"$sql_file" +} + +# Helper: run sqlplus as test user inside the Oracle container +run_user() { + local sql_file="$1" + docker exec "$ORACLE_CONTAINER" sqlplus -S "$DB_CONN" @"$sql_file" +} + +# Helper: copy file into Oracle container +copy_in() { + docker cp "$1" "${ORACLE_CONTAINER}:$2" +} + +# Helper: copy file out of Oracle container +copy_out() { + docker cp "${ORACLE_CONTAINER}:$1" "$2" +} + +# Check for DDL marker — switches to DICT_FROM_REDO_LOGS mode +DDL_MODE=0 +if grep -q '^-- @DDL' "$SCENARIO_SQL" 2>/dev/null; then + DDL_MODE=1 +fi + +# Check for @MID_SWITCH markers +MID_SWITCH_COUNT=$(grep -c '^-- @MID_SWITCH' "$SCENARIO_SQL" 2>/dev/null || true) + +# Generated fixture name encodes scenario + environment +FIXTURE_NAME="${SCENARIO}-${ORACLE_TARGET}" + +echo "=== Fixture generation: $SCENARIO (target: $ORACLE_TARGET) ===" +echo " Container: $ORACLE_CONTAINER" +echo " Fixture name: $FIXTURE_NAME" +echo " Work dir: $WORK_DIR" +if [[ "$DDL_MODE" -eq 1 ]]; then + echo " Mode: DDL (DICT_FROM_REDO_LOGS)" +fi +echo "" + +# ---- Stage 0 (DDL only): Build LogMiner dictionary into redo logs ---- +if [[ "$DDL_MODE" -eq 1 ]]; then + echo "--- Stage 0: Building LogMiner dictionary into redo logs ---" + cat > "$WORK_DIR/build_dict.sql" <<'DICTSQL' +SET SERVEROUTPUT ON FEEDBACK OFF +BEGIN + DBMS_LOGMNR_D.BUILD(OPTIONS => DBMS_LOGMNR_D.STORE_IN_REDO_LOGS); + DBMS_OUTPUT.PUT_LINE('Dictionary built OK'); +END; +/ +ALTER SYSTEM SWITCH LOGFILE; +BEGIN DBMS_SESSION.SLEEP(2); END; +/ +EXIT +DICTSQL + copy_in "$WORK_DIR/build_dict.sql" /tmp/build_dict.sql + DICT_OUTPUT=$(run_sysdba /tmp/build_dict.sql) + echo " $DICT_OUTPUT" + + # Record the SCN where dictionary starts + cat > "$WORK_DIR/dict_scn.sql" <<'DICTSCN' +SET HEADING OFF FEEDBACK OFF PAGESIZE 0 +SELECT MIN(first_change#) FROM v$archived_log +WHERE dictionary_begin = 'YES' AND deleted = 'NO' AND name IS NOT NULL + AND first_change# = (SELECT MAX(first_change#) FROM v$archived_log + WHERE dictionary_begin = 'YES' AND deleted = 'NO'); +EXIT +DICTSCN + copy_in "$WORK_DIR/dict_scn.sql" /tmp/dict_scn.sql + DICT_START_SCN=$(run_sysdba /tmp/dict_scn.sql | tr -d '[:space:]') + echo " Dictionary start SCN: $DICT_START_SCN" + echo "" +fi + +# ---- Stage 1: Run SQL scenario ---- +echo "--- Stage 1: Running SQL scenario ---" +copy_in "$SCENARIO_SQL" /tmp/scenario.sql + +if [[ "$MID_SWITCH_COUNT" -gt 0 ]]; then + echo " Detected $MID_SWITCH_COUNT @MID_SWITCH marker(s) — running DML in background" + run_user /tmp/scenario.sql > "$WORK_DIR/dml_output.txt" 2>&1 & + DML_PID=$! + for i in $(seq 1 "$MID_SWITCH_COUNT"); do + sleep 8 + echo " Triggering mid-execution log switch #$i" + cat > "$WORK_DIR/mid_switch.sql" <<'MIDSQL' +SET FEEDBACK OFF +ALTER SYSTEM SWITCH LOGFILE; +EXIT +MIDSQL + copy_in "$WORK_DIR/mid_switch.sql" /tmp/mid_switch.sql + run_sysdba /tmp/mid_switch.sql > /dev/null + done + wait "$DML_PID" || true + SCENARIO_OUTPUT=$(cat "$WORK_DIR/dml_output.txt") +else + SCENARIO_OUTPUT=$(run_user /tmp/scenario.sql) +fi +echo "$SCENARIO_OUTPUT" + +# Parse SCN range from output +START_SCN=$(echo "$SCENARIO_OUTPUT" | grep 'FIXTURE_SCN_START:' | head -1 | sed 's/.*FIXTURE_SCN_START:\s*//' | tr -d '[:space:]') +if [[ -z "$START_SCN" ]]; then + echo "ERROR: Could not find FIXTURE_SCN_START in scenario output" >&2 + exit 1 +fi + +# Force log switches +echo " Forcing log switches..." +cat > "$WORK_DIR/log_switch.sql" <<'LOGSQL' +SET FEEDBACK OFF +ALTER SYSTEM SWITCH LOGFILE; +ALTER SYSTEM SWITCH LOGFILE; +BEGIN DBMS_SESSION.SLEEP(3); END; +/ +EXIT +LOGSQL +copy_in "$WORK_DIR/log_switch.sql" /tmp/log_switch.sql +run_sysdba /tmp/log_switch.sql > /dev/null + +# Get end SCN +cat > "$WORK_DIR/get_scn.sql" <<'SCNSQL' +SET HEADING OFF FEEDBACK OFF PAGESIZE 0 +SELECT current_scn FROM v$database; +EXIT +SCNSQL +copy_in "$WORK_DIR/get_scn.sql" /tmp/get_scn.sql +END_SCN=$(run_sysdba /tmp/get_scn.sql | tr -d '[:space:]') + +echo " SCN range: $START_SCN - $END_SCN" + +# ---- Stage 2: Capture archived redo logs ---- +echo "" +echo "--- Stage 2: Capturing archived redo logs ---" +REDO_DIR="$TESTS_DIR/3-generated/redo/$FIXTURE_NAME" +rm -rf "$REDO_DIR" +mkdir -p "$REDO_DIR" + +# Query log_archive_format from Oracle +cat > "$WORK_DIR/get_archfmt.sql" <<'FMTSQL' +SET HEADING OFF FEEDBACK OFF PAGESIZE 0 LINESIZE 200 +SELECT value FROM v$parameter WHERE name='log_archive_format'; +EXIT +FMTSQL +copy_in "$WORK_DIR/get_archfmt.sql" /tmp/get_archfmt.sql +LOG_ARCHIVE_FORMAT=$(run_sysdba /tmp/get_archfmt.sql | tr -d '[:space:]') +echo " Oracle log_archive_format: $LOG_ARCHIVE_FORMAT" + +# Query archive files with thread#/sequence# to detect filename prefixes +cat > "$WORK_DIR/find_archives.sql" <= $START_SCN + AND deleted = 'NO' + AND name IS NOT NULL +ORDER BY thread#, sequence#; +EXIT +SQL + +copy_in "$WORK_DIR/find_archives.sql" /tmp/find_archives.sql +ARCHIVE_LIST=$(run_sysdba /tmp/find_archives.sql) + +if [[ -z "$ARCHIVE_LIST" ]]; then + echo "ERROR: No archive logs found for SCN range" >&2 + exit 1 +fi + +# Detect actual filename prefix by comparing first archive with expected format +# Oracle may add prefixes (e.g., "arch") not reflected in log_archive_format +FIRST_LINE=$(echo "$ARCHIVE_LIST" | head -1 | tr -d '[:space:]') +FIRST_PATH=$(echo "$FIRST_LINE" | cut -d'|' -f1) +FIRST_THREAD=$(echo "$FIRST_LINE" | cut -d'|' -f2) +FIRST_SEQ=$(echo "$FIRST_LINE" | cut -d'|' -f3) +FIRST_RESETLOGS=$(echo "$FIRST_LINE" | cut -d'|' -f4) +FIRST_FNAME=$(basename "$FIRST_PATH") + +# Construct what the format would produce for this file +EXPECTED_FNAME=$(echo "$LOG_ARCHIVE_FORMAT" | sed "s/%t/$FIRST_THREAD/;s/%s/$FIRST_SEQ/;s/%r/$FIRST_RESETLOGS/;s/%S/$(printf '%09d' "$FIRST_SEQ")/") +# Derive prefix: strip expected from actual +ARCHIVE_PREFIX="${FIRST_FNAME%%$EXPECTED_FNAME}" +if [[ -n "$ARCHIVE_PREFIX" ]]; then + echo " Detected archive filename prefix: '$ARCHIVE_PREFIX'" + LOG_ARCHIVE_FORMAT="${ARCHIVE_PREFIX}${LOG_ARCHIVE_FORMAT}" +fi +echo " Effective log-archive-format: $LOG_ARCHIVE_FORMAT" + +echo "$ARCHIVE_LIST" | while read -r line; do + line=$(echo "$line" | tr -d '[:space:]') + [[ -z "$line" ]] && continue + arclog=$(echo "$line" | cut -d'|' -f1) + fname=$(basename "$arclog") + echo " Copying: $arclog" + copy_out "$arclog" "$REDO_DIR/$fname" +done +chmod -R a+r "$REDO_DIR" # Oracle archives are 640; make readable for OLR container +echo " Redo logs saved to: $REDO_DIR" + +# ---- Stage 3: Generate schema file ---- +echo "" +echo "--- Stage 3: Schema generation ---" +SCHEMA_DIR="$TESTS_DIR/3-generated/schema/$FIXTURE_NAME" +rm -rf "$SCHEMA_DIR" +mkdir -p "$SCHEMA_DIR" + +# Patch gencfg.sql with test parameters +cp "$PROJECT_ROOT/scripts/gencfg.sql" "$WORK_DIR/gencfg.sql" + +# Patch: name, users, SCN +sed -i "s/v_NAME := 'DB'/v_NAME := 'TEST'/" "$WORK_DIR/gencfg.sql" +sed -i "s/v_USERNAME_LIST := VARCHAR2TABLE('USR1', 'USR2')/v_USERNAME_LIST := VARCHAR2TABLE('$SCHEMA_OWNER')/" "$WORK_DIR/gencfg.sql" +sed -i "s/SELECT CURRENT_SCN INTO v_SCN FROM SYS.V_\\\$DATABASE/-- SELECT CURRENT_SCN INTO v_SCN FROM SYS.V_\$DATABASE/" "$WORK_DIR/gencfg.sql" +sed -i "s/-- v_SCN := 12345678/v_SCN := $START_SCN/" "$WORK_DIR/gencfg.sql" + +# Add PDB session switch and settings before the DECLARE block +sed -i '/^SET LINESIZE/i ALTER SESSION SET CONTAINER='"$PDB_NAME"';\nSET FEEDBACK OFF\nSET ECHO OFF' "$WORK_DIR/gencfg.sql" + +# Add EXIT at end +echo "EXIT;" >> "$WORK_DIR/gencfg.sql" + +copy_in "$WORK_DIR/gencfg.sql" /tmp/gencfg.sql + +echo " Running gencfg.sql..." +GENCFG_OUTPUT=$(run_sysdba /tmp/gencfg.sql) + +# Extract JSON content (starts with {"database":) +SCHEMA_FILE="$SCHEMA_DIR/TEST-chkpt-${START_SCN}.json" +echo "$GENCFG_OUTPUT" | sed -n '/^{"database"/,$p' > "$SCHEMA_FILE" + +if [[ ! -s "$SCHEMA_FILE" ]]; then + echo "ERROR: gencfg.sql produced no JSON output" >&2 + echo "Output was:" >&2 + echo "$GENCFG_OUTPUT" >&2 + exit 1 +fi + +# Fix seq to 0 for batch mode +python3 -c " +import json, sys +with open('$SCHEMA_FILE') as f: + data = json.load(f) +data['seq'] = 0 +with open('$SCHEMA_FILE', 'w') as f: + json.dump(data, f, separators=(',', ':')) +" +echo " Schema file: $SCHEMA_FILE ($(wc -c < "$SCHEMA_FILE") bytes)" + +# ---- Stage 4: Run LogMiner extraction ---- +echo "" +echo "--- Stage 4: Running LogMiner extraction ---" + +if [[ "$DDL_MODE" -eq 1 ]]; then + LM_ARCHIVE_FILTER="first_change# <= $END_SCN AND next_change# >= $DICT_START_SCN" + LM_OPTIONS="DBMS_LOGMNR.DICT_FROM_REDO_LOGS + DBMS_LOGMNR.DDL_DICT_TRACKING + DBMS_LOGMNR.NO_ROWID_IN_STMT + DBMS_LOGMNR.COMMITTED_DATA_ONLY" + LM_MODE_DESC="DICT_FROM_REDO_LOGS + DDL_DICT_TRACKING" +else + LM_ARCHIVE_FILTER="first_change# <= $END_SCN AND next_change# >= $START_SCN" + LM_OPTIONS="DBMS_LOGMNR.DICT_FROM_ONLINE_CATALOG + DBMS_LOGMNR.NO_ROWID_IN_STMT + DBMS_LOGMNR.COMMITTED_DATA_ONLY" + LM_MODE_DESC="DICT_FROM_ONLINE_CATALOG" +fi +echo " LogMiner mode: $LM_MODE_DESC" + +cat > "$WORK_DIR/logminer_run.sql" < rec.name, + options => CASE WHEN v_count = 0 + THEN DBMS_LOGMNR.NEW + ELSE DBMS_LOGMNR.ADDFILE + END + ); + v_count := v_count + 1; + DBMS_OUTPUT.PUT_LINE('Added log: ' || rec.name); + END LOOP; + + IF v_count = 0 THEN + DBMS_OUTPUT.PUT_LINE('ERROR: No archive logs found for SCN range'); + RETURN; + END IF; + + DBMS_OUTPUT.PUT_LINE('Starting LogMiner with ' || v_count || ' log file(s)'); + + DBMS_LOGMNR.START_LOGMNR( + startScn => $START_SCN, + endScn => $END_SCN, + options => $LM_OPTIONS + ); +END; +/ + +SPOOL /tmp/logminer_out.lst + +SELECT TO_CLOB(scn || '|' || operation || '|' || seg_owner || '|' || table_name || '|' || xid || '|') || + REPLACE(REPLACE(sql_redo, CHR(10), ' '), CHR(13), '') || '|' || + REPLACE(REPLACE(NVL(sql_undo, ''), CHR(10), ' '), CHR(13), '') +FROM v\$logmnr_contents +WHERE seg_owner = UPPER('$SCHEMA_OWNER') + AND operation IN ('INSERT', 'UPDATE', 'DELETE') +ORDER BY scn, xid, sequence#; + +SPOOL OFF + +BEGIN + DBMS_LOGMNR.END_LOGMNR; +END; +/ + +EXIT +SQL + +copy_in "$WORK_DIR/logminer_run.sql" /tmp/logminer_run.sql + +echo " Running LogMiner..." +LM_OUTPUT=$(run_sysdba /tmp/logminer_run.sql) +echo "$LM_OUTPUT" | head -20 || true + +copy_out /tmp/logminer_out.lst "$WORK_DIR/logminer_raw.lst" + +python3 "$SCRIPT_DIR/logminer2json.py" "$WORK_DIR/logminer_raw.lst" "$WORK_DIR/logminer.json" +LM_COUNT=$(wc -l < "$WORK_DIR/logminer.json") +echo " LogMiner records: $LM_COUNT" + +# ---- Stage 5: Run OLR in batch mode (via olr dev container) ---- +echo "" +echo "--- Stage 5: Running OLR ---" + +# Backup schema file before OLR (OLR modifies the schema dir with checkpoints) +cp "$SCHEMA_FILE" "$WORK_DIR/schema_backup.json" + +# Compute container-side paths (tests/ is mounted at CONTAINER_TESTS) +WORK_DIR_REL="${WORK_DIR#$TESTS_DIR/}" +C_WORK="$CONTAINER_TESTS/$WORK_DIR_REL" +C_REDO="$CONTAINER_TESTS/3-generated/redo/$FIXTURE_NAME" +C_SCHEMA="$CONTAINER_TESTS/3-generated/schema/$FIXTURE_NAME" + +# Build redo-log JSON array using container paths +REDO_FILES_JSON="" +for f in "$REDO_DIR"/*; do + [[ -f "$f" ]] || continue + fname=$(basename "$f") + if [[ -n "$REDO_FILES_JSON" ]]; then + REDO_FILES_JSON="$REDO_FILES_JSON, " + fi + REDO_FILES_JSON="$REDO_FILES_JSON\"$C_REDO/$fname\"" +done + +OLR_OUTPUT="$WORK_DIR/olr_output.json" + +# Config uses container paths — tests/ is bind-mounted into the olr container +cat > "$WORK_DIR/olr_config.json" < "$WORK_DIR/olr_stdout.log" 2>&1; then + echo "ERROR: OLR exited with non-zero status" >&2 + cat "$WORK_DIR/olr_stdout.log" >&2 + exit 1 +fi + +if [[ ! -f "$OLR_OUTPUT" ]]; then + echo "ERROR: OLR did not produce output file" >&2 + cat "$WORK_DIR/olr_stdout.log" >&2 + exit 1 +fi + +OLR_LINES=$(wc -l < "$OLR_OUTPUT") +echo " OLR output lines: $OLR_LINES" + +# Clean up runtime checkpoint files and restore original schema +rm -f "$SCHEMA_DIR"/TEST-chkpt.json "$SCHEMA_DIR"/TEST-chkpt-*.json +cp "$WORK_DIR/schema_backup.json" "$SCHEMA_FILE" + +# ---- Stage 6: Compare ---- +echo "" +echo "--- Stage 6: Comparing LogMiner vs OLR ---" +if python3 "$SCRIPT_DIR/compare.py" "$WORK_DIR/logminer.json" "$OLR_OUTPUT"; then + COMPARE_RESULT=0 +else + COMPARE_RESULT=1 +fi + +# ---- Stage 7: Save golden file ---- +echo "" +if [[ $COMPARE_RESULT -eq 0 ]]; then + echo "--- Stage 7: Saving golden file ---" + EXPECTED_DIR="$TESTS_DIR/3-generated/expected/$FIXTURE_NAME" + mkdir -p "$EXPECTED_DIR" + cp "$OLR_OUTPUT" "$EXPECTED_DIR/output.json" + echo " Golden file saved: $EXPECTED_DIR/output.json" + + cp "$WORK_DIR/logminer.json" "$EXPECTED_DIR/logminer-reference.json" + echo " LogMiner reference saved: $EXPECTED_DIR/logminer-reference.json" + echo "" + echo "=== PASS: Fixture '$SCENARIO' generated successfully ===" +else + echo "--- Stage 7: SKIPPED (comparison failed) ---" + echo "" + echo "=== FAIL: Fixture '$SCENARIO' comparison failed ===" + echo " LogMiner JSON: $WORK_DIR/logminer.json" + echo " OLR output: $OLR_OUTPUT" + echo " OLR log: $WORK_DIR/olr_stdout.log" + echo "" + echo "Debug: inspect the files above, then re-run after fixing." + trap - EXIT # preserve work dir for debugging + exit 1 +fi diff --git a/tests/scripts/logminer2json.py b/tests/scripts/logminer2json.py new file mode 100644 index 00000000..989cb926 --- /dev/null +++ b/tests/scripts/logminer2json.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Convert LogMiner pipe-delimited output to canonical JSON for comparison. + +Input format (one line per DML statement): + SCN|OPERATION|SEG_OWNER|TABLE_NAME|XID|SQL_REDO|SQL_UNDO + +Output: one JSON object per line with normalized fields: + {"scn": "...", "op": "INSERT|UPDATE|DELETE", "owner": "...", "table": "...", + "xid": "...", "after": {...}, "before": {...}} + +All column values are stored as strings for type-agnostic comparison. +""" + +import json +import re +import sys + + +def parse_insert(sql_redo): + """Parse: insert into "OWNER"."TABLE"("COL1","COL2",...) values ('v1','v2',...)""" + m = re.match( + r'insert into "[^"]*"\."[^"]*"\((.+?)\)\s+values\s+\((.+)\)\s*;?\s*$', + sql_redo, re.IGNORECASE | re.DOTALL + ) + if not m: + return None + cols = parse_column_list(m.group(1)) + vals = parse_value_list(m.group(2)) + if len(cols) != len(vals): + return None + return {"after": dict(zip(cols, vals))} + + +def parse_update(sql_redo): + """Parse: update "OWNER"."TABLE" set "COL1" = 'v1', ... where "COL2" = 'v2' and ...""" + m = re.match( + r'update "[^"]*"\."[^"]*"\s+set\s+(.+?)\s+where\s+(.+)\s*;?\s*$', + sql_redo, re.IGNORECASE | re.DOTALL + ) + if not m: + return None + after = parse_assignments(m.group(1)) + before = parse_where_clause(m.group(2)) + return {"before": before, "after": after} + + +def parse_delete(sql_redo): + """Parse: delete from "OWNER"."TABLE" where "COL1" = 'v1' and ...""" + m = re.match( + r'delete from "[^"]*"\."[^"]*"\s+where\s+(.+)\s*;?\s*$', + sql_redo, re.IGNORECASE | re.DOTALL + ) + if not m: + return None + before = parse_where_clause(m.group(1)) + return {"before": before} + + +def parse_column_list(s): + """Parse quoted column names: "COL1","COL2",... """ + return re.findall(r'"([^"]+)"', s) + + +def parse_value_list(s): + """Parse values: 'v1','v2',NULL,TO_DATE(...),... """ + values = [] + i = 0 + while i < len(s): + c = s[i] + if c in (' ', ','): + i += 1 + continue + if c == "'": + # Quoted string — handle escaped quotes ('') + j = i + 1 + val = [] + while j < len(s): + if s[j] == "'" and j + 1 < len(s) and s[j + 1] == "'": + val.append("'") + j += 2 + elif s[j] == "'": + j += 1 + break + else: + val.append(s[j]) + j += 1 + values.append("".join(val)) + i = j + elif s[i:i+4].upper() == 'NULL': + values.append(None) + i += 4 + elif s[i:i+7].upper() == 'TO_DATE': + # TO_DATE('...','...') — extract the date string + m = re.match(r"TO_DATE\('([^']*)'", s[i:], re.IGNORECASE) + if m: + values.append(m.group(1)) + else: + values.append(s[i:]) + # Skip to matching closing paren + depth = 0 + while i < len(s): + if s[i] == '(': + depth += 1 + elif s[i] == ')': + depth -= 1 + if depth == 0: + i += 1 + break + i += 1 + elif s[i:i+12].upper() == 'TO_TIMESTAMP': + m = re.match(r"TO_TIMESTAMP\('([^']*)'", s[i:], re.IGNORECASE) + if m: + values.append(m.group(1)) + else: + values.append(s[i:]) + depth = 0 + while i < len(s): + if s[i] == '(': + depth += 1 + elif s[i] == ')': + depth -= 1 + if depth == 0: + i += 1 + break + i += 1 + elif s[i:i+8].upper() == 'HEXTORAW': + m = re.match(r"HEXTORAW\('([^']*)'\)", s[i:], re.IGNORECASE) + if m: + values.append(m.group(1)) + else: + values.append(s[i:]) + depth = 0 + while i < len(s): + if s[i] == '(': + depth += 1 + elif s[i] == ')': + depth -= 1 + if depth == 0: + i += 1 + break + i += 1 + else: + # Unquoted number or other literal + j = i + while j < len(s) and s[j] not in (',', ' '): + j += 1 + if j == i: + # Skip unrecognized character to avoid infinite loop + i += 1 + continue + values.append(s[i:j]) + i = j + return values + + +def parse_assignments(s): + """Parse SET clause: "COL1" = 'v1', "COL2" = 'v2', ...""" + result = {} + # Match "COL" = value patterns + pattern = r'"([^"]+)"\s*=\s*' + parts = re.split(r',\s*(?=")', s) + for part in parts: + m = re.match(r'\s*"([^"]+)"\s*=\s*(.+)$', part.strip(), re.DOTALL) + if m: + col = m.group(1) + val_str = m.group(2).strip() + result[col] = extract_value(val_str) + return result + + +def parse_where_clause(s): + """Parse WHERE clause: "COL1" = 'v1' and "COL2" = 'v2' and ...""" + result = {} + # Split on ' and ' (case-insensitive) but not within quotes + parts = re.split(r'\s+and\s+', s, flags=re.IGNORECASE) + for part in parts: + m = re.match(r'\s*"([^"]+)"\s*=\s*(.+)$', part.strip(), re.DOTALL) + if m: + col = m.group(1) + val_str = m.group(2).strip() + result[col] = extract_value(val_str) + # Handle IS NULL + m2 = re.match(r'\s*"([^"]+)"\s+IS\s+NULL', part.strip(), re.IGNORECASE) + if m2: + result[m2.group(1)] = None + return result + + +def extract_value(val_str): + """Extract a single value from SQL expression.""" + val_str = val_str.rstrip(';').strip() + if val_str.upper() == 'NULL': + return None + if val_str.startswith("'") and val_str.endswith("'"): + # Unescape '' + return val_str[1:-1].replace("''", "'") + m = re.match(r"TO_DATE\('([^']*)'", val_str, re.IGNORECASE) + if m: + return m.group(1) + m = re.match(r"TO_TIMESTAMP\('([^']*)'", val_str, re.IGNORECASE) + if m: + return m.group(1) + m = re.match(r"HEXTORAW\('([^']*)'\)", val_str, re.IGNORECASE) + if m: + return m.group(1) + return val_str + + +def convert_line(line): + """Convert one pipe-delimited LogMiner line to a dict.""" + parts = line.split('|', 6) + if len(parts) < 7: + return None + + scn, operation, seg_owner, table_name, xid, sql_redo, sql_undo = parts + operation = operation.strip() + sql_redo = sql_redo.strip() + + record = { + "scn": scn.strip(), + "op": operation, + "owner": seg_owner.strip(), + "table": table_name.strip(), + "xid": xid.strip(), + } + + if operation == 'INSERT': + parsed = parse_insert(sql_redo) + elif operation == 'UPDATE': + parsed = parse_update(sql_redo) + elif operation == 'DELETE': + parsed = parse_delete(sql_redo) + else: + return None + + if parsed is None: + print(f"WARNING: Failed to parse SQL_REDO: {sql_redo}", file=sys.stderr) + record["after"] = {} + record["before"] = {} + return record + + record.update(parsed) + return record + + +SQL_START_RE = re.compile(r'^(insert into|update|delete from)\s+"', re.IGNORECASE) + + +def merge_continuation_lines(lines): + """Merge LogMiner continuation rows for long SQL_REDO/SQL_UNDO. + + When SQL_REDO exceeds ~4000 chars, LogMiner splits it across multiple rows + with the same scn|op|owner|table|xid prefix. Continuation rows have sql_redo + that doesn't start with an SQL keyword (insert/update/delete). + """ + merged = [] + accum = None # (header_parts[0:5], sql_redo, sql_undo) + for line in lines: + parts = line.split('|', 6) + if len(parts) < 6: + continue + sql_redo = parts[5] if len(parts) > 5 else '' + sql_undo = parts[6] if len(parts) > 6 else '' + if accum and not SQL_START_RE.match(sql_redo.strip()): + accum = (accum[0], accum[1] + sql_redo, accum[2] + sql_undo) + else: + if accum: + merged.append('|'.join(accum[0]) + '|' + accum[1] + '|' + accum[2]) + accum = (parts[:5], sql_redo, sql_undo) + if accum: + merged.append('|'.join(accum[0]) + '|' + accum[1] + '|' + accum[2]) + return merged + + +def main(): + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} [output-file]", file=sys.stderr) + sys.exit(1) + + input_file = sys.argv[1] + output_file = sys.argv[2] if len(sys.argv) > 2 else None + + raw_lines = [] + with open(input_file) as f: + for line in f: + line = line.strip() + if not line or line.startswith('--') or line.startswith('SQL>'): + continue + raw_lines.append(line) + + merged_lines = merge_continuation_lines(raw_lines) + + records = [] + for line in merged_lines: + rec = convert_line(line) + if rec: + records.append(rec) + + output = '\n'.join(json.dumps(r, sort_keys=True) for r in records) + '\n' + + if output_file: + with open(output_file, 'w') as f: + f.write(output) + else: + sys.stdout.write(output) + + +if __name__ == '__main__': + main() diff --git a/tests/scripts/oracle-init/01-setup.sh b/tests/scripts/oracle-init/01-setup.sh new file mode 100755 index 00000000..63711816 --- /dev/null +++ b/tests/scripts/oracle-init/01-setup.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Enable archivelog mode and supplemental logging for OLR testing. +# Runs as part of gvenzl/oracle-free container initialization. + +sqlplus -S / as sysdba <<'SQL' +SHUTDOWN IMMEDIATE; +STARTUP MOUNT; +ALTER DATABASE ARCHIVELOG; +ALTER DATABASE OPEN; +ALTER DATABASE ADD SUPPLEMENTAL LOG DATA; +ALTER SYSTEM SET db_recovery_file_dest_size=10G; + +-- Grant PDB user access to v$database for SCN queries in test scenarios +ALTER SESSION SET CONTAINER=FREEPDB1; +GRANT SELECT ON SYS.V_$DATABASE TO olr_test; +SQL diff --git a/tests/test_pipeline.cpp b/tests/test_pipeline.cpp new file mode 100644 index 00000000..5e9b387e --- /dev/null +++ b/tests/test_pipeline.cpp @@ -0,0 +1,345 @@ +/* Full pipeline I/O tests for OpenLogReplicator + Runs OLR binary in batch mode with redo log fixtures and compares JSON output against golden files. */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + const std::string OLR_BIN = OLR_BINARY_PATH; + const std::string TEST_DATA = OLR_TEST_DATA_DIR; + + struct OlrResult { + int exitCode; + std::string output; + }; + + // Run the OLR binary with a given config file. Returns exit code and captured stderr+stdout. + OlrResult runOLR(const std::string& configPath) { + // -r: allow running as root (needed in some CI containers) + // -f: config file path + std::string cmd = OLR_BIN + " -r -f " + configPath + " 2>&1"; + + std::string output; + std::array buf{}; + FILE* pipe = popen(cmd.c_str(), "r"); + if (!pipe) + return {-1, "popen failed"}; + + while (fgets(buf.data(), buf.size(), pipe) != nullptr) + output += buf.data(); + + int status = pclose(pipe); + int exitCode = WIFEXITED(status) ? WEXITSTATUS(status) : -1; + return {exitCode, output}; + } + + // Read file as vector of non-empty lines. + std::vector readLines(const std::string& path) { + std::vector lines; + std::ifstream f(path); + std::string line; + while (std::getline(f, line)) { + if (!line.empty()) + lines.push_back(line); + } + return lines; + } + + // Write string to file. + void writeFile(const std::string& path, const std::string& content) { + std::ofstream f(path); + f << content; + } + + // Compare actual output file against expected golden file, line by line. + // Returns empty string on match, or a description of the first difference. + std::string compareGoldenFile(const std::string& actualPath, const std::string& expectedPath) { + auto actual = readLines(actualPath); + auto expected = readLines(expectedPath); + + size_t maxLines = std::max(actual.size(), expected.size()); + for (size_t i = 0; i < maxLines; ++i) { + if (i >= actual.size()) + return "actual has fewer lines than expected (actual: " + std::to_string(actual.size()) + + ", expected: " + std::to_string(expected.size()) + ")"; + if (i >= expected.size()) + return "actual has more lines than expected (actual: " + std::to_string(actual.size()) + + ", expected: " + std::to_string(expected.size()) + ")"; + if (actual[i] != expected[i]) + return "line " + std::to_string(i + 1) + " differs:\n actual: " + actual[i] + "\n expected: " + expected[i]; + } + return {}; + } + + // Resolve the parent directory for a fixture based on its prefix. + // Fixture names are "prebuilt/" or "generated/". + // Returns the base directory (2-prebuilt or 3-generated) under TEST_DATA. + std::pair parseFixtureName(const std::string& name) { + auto slashPos = name.find('/'); + if (slashPos == std::string::npos) + return {"", name}; + std::string prefix = name.substr(0, slashPos); + std::string scenario = name.substr(slashPos + 1); + if (prefix == "prebuilt") + return {"2-prebuilt", scenario}; + if (prefix == "generated") + return {"3-generated", scenario}; + return {"", name}; + } +} + +class PipelineTest : public ::testing::Test { +protected: + fs::path tmpDir; + + void SetUp() override { + // Create a unique temp directory for this test + tmpDir = fs::temp_directory_path() / ("olr_test_" + std::to_string(getpid()) + "_" + std::to_string(rand())); + fs::create_directories(tmpDir); + } + + void TearDown() override { + if (fs::exists(tmpDir)) + fs::remove_all(tmpDir); + } + + // Check if a test fixture set exists. + bool hasFixture(const std::string& name) { + auto [baseDir, scenario] = parseFixtureName(name); + if (baseDir.empty()) + return false; + fs::path redoDir = fs::path(TEST_DATA) / baseDir / "redo" / scenario; + fs::path expectedDir = fs::path(TEST_DATA) / baseDir / "expected" / scenario; + return fs::exists(redoDir) && fs::exists(expectedDir); + } + + // Build a batch-mode config JSON for a given fixture. + // Discovers all .arc files in the fixture redo directory. + // If a schema checkpoint file exists, uses schema mode; otherwise schemaless (flags:2). + std::string buildBatchConfig(const std::string& fixtureName, const std::string& outputPath) { + auto [baseDir, scenario] = parseFixtureName(fixtureName); + fs::path redoDir = fs::path(TEST_DATA) / baseDir / "redo" / scenario; + fs::path schemaDir = fs::path(TEST_DATA) / baseDir / "schema" / scenario; + + // Collect all redo log files + std::vector redoFiles; + for (const auto& entry : fs::directory_iterator(redoDir)) { + if (entry.is_regular_file()) + redoFiles.push_back(entry.path().string()); + } + std::sort(redoFiles.begin(), redoFiles.end()); + + // Build redo-log JSON array + std::string redoLogArray = "["; + for (size_t i = 0; i < redoFiles.size(); ++i) { + if (i > 0) redoLogArray += ", "; + redoLogArray += "\"" + redoFiles[i] + "\""; + } + redoLogArray += "]"; + + // Detect schema checkpoint files (TEST-chkpt-.json). + // If multiple exist, use the one with the lowest SCN (the start checkpoint). + // Copy to tmpDir to avoid OLR writing runtime checkpoints into source schema dir. + bool hasSchema = false; + std::string startScn; + fs::path bestSchemaPath; + long long bestScnNum = LLONG_MAX; + if (fs::exists(schemaDir)) { + for (const auto& entry : fs::directory_iterator(schemaDir)) { + std::string fname = entry.path().filename().string(); + if (fname.substr(0, 10) == "TEST-chkpt" && fname.length() > 15 && fname.substr(fname.length() - 5) == ".json") { + std::string scnStr = fname.substr(10); // "-.json" + if (scnStr[0] == '-') { + scnStr = scnStr.substr(1, scnStr.length() - 6); + try { + long long scnNum = std::stoll(scnStr); + if (scnNum < bestScnNum) { + bestScnNum = scnNum; + startScn = scnStr; + bestSchemaPath = entry.path(); + hasSchema = true; + } + } catch (...) {} + } + } + } + } + if (hasSchema) + fs::copy_file(bestSchemaPath, tmpDir / bestSchemaPath.filename()); + + // Use tmpDir as state path so runtime checkpoints don't pollute schema dir + std::string statePath = tmpDir.string(); + + // Detect log-archive-format from actual redo log filenames. + // Finds the first redo file and derives the format by replacing + // thread/sequence/resetlogs numbers with OLR format specifiers. + std::string archiveFormat; + if (!redoFiles.empty()) { + std::string sample = fs::path(redoFiles[0]).filename().string(); + // Extract numbers separated by underscores from the filename stem + // Expected pattern: [prefix]__. + std::string stem = sample.substr(0, sample.find_last_of('.')); + std::string ext = sample.substr(sample.find_last_of('.')); + + // Find the positions of the last three underscore-separated numbers + // by working backwards from the stem + size_t pos2 = stem.find_last_of('_'); + size_t pos1 = (pos2 != std::string::npos) ? stem.find_last_of('_', pos2 - 1) : std::string::npos; + if (pos1 != std::string::npos && pos2 != std::string::npos) { + // Find where the thread number starts (first digit before pos1) + size_t threadStart = pos1; + while (threadStart > 0 && std::isdigit(stem[threadStart - 1])) + threadStart--; + std::string prefix = stem.substr(0, threadStart); + archiveFormat = prefix + "%t_%s_%r" + ext; + } + } + if (archiveFormat.empty()) + archiveFormat = "%t_%s_%r.dbf"; + + // Reader section: add start-scn and log-archive-format for schema mode + std::string readerExtra; + std::string flagsLine; + std::string filterSection; + if (hasSchema) { + readerExtra = R"(, + "log-archive-format": ")" + archiveFormat + R"(", + "start-scn": )" + startScn; + flagsLine = ""; + filterSection = R"(, + "filter": { + "table": [ + {"owner": "OLR_TEST", "table": ".*"} + ] + })"; + } else { + readerExtra = R"(, + "log-archive-format": "")"; + flagsLine = R"(, + "flags": 2)"; + filterSection = ""; + } + + std::string config = R"({ + "version": "1.9.0", + "log-level": 3, + "memory": { + "min-mb": 32, + "max-mb": 256 + }, + "state": { + "type": "disk", + "path": ")" + statePath + R"(" + }, + "source": [ + { + "alias": "S1", + "name": "TEST", + "reader": { + "type": "batch", + "redo-log": )" + redoLogArray + readerExtra + R"( + }, + "format": { + "type": "json", + "scn": 1, + "timestamp": 7, + "timestamp-metadata": 7, + "xid": 1 + })" + flagsLine + filterSection + R"( + } + ], + "target": [ + { + "alias": "T1", + "source": "S1", + "writer": { + "type": "file", + "output": ")" + outputPath + R"(", + "new-line": 1, + "append": 1 + } + } + ] +})"; + return config; + } +}; + +// --- Auto-discovered parameterized fixtures --- +// Discovers fixture names from both 2-prebuilt/ and 3-generated/ directories. +// Each fixture is prefixed with its source: "prebuilt/" or "generated/". + +namespace { + void scanFixtureDir(const std::string& baseDir, const std::string& prefix, std::vector& fixtures) { + fs::path expectedDir = fs::path(TEST_DATA) / baseDir / "expected"; + fs::path redoDir = fs::path(TEST_DATA) / baseDir / "redo"; + + if (!fs::exists(expectedDir) || !fs::exists(redoDir)) + return; + + for (const auto& entry : fs::directory_iterator(expectedDir)) { + if (!entry.is_directory()) + continue; + std::string name = entry.path().filename().string(); + if (fs::exists(entry.path() / "output.json") && fs::exists(redoDir / name)) + fixtures.push_back(prefix + "/" + name); + } + } + + std::vector discoverFixtures() { + std::vector fixtures; + scanFixtureDir("2-prebuilt", "prebuilt", fixtures); + scanFixtureDir("3-generated", "generated", fixtures); + std::sort(fixtures.begin(), fixtures.end()); + return fixtures; + } +} + +class PipelineParamTest : public PipelineTest, + public ::testing::WithParamInterface {}; + +GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(PipelineParamTest); + +TEST_P(PipelineParamTest, BatchFixture) { + std::string fixtureName = GetParam(); + ASSERT_TRUE(hasFixture(fixtureName)) << "Fixture '" << fixtureName << "' not found — run fixture generation first."; + + std::string outputPath = (tmpDir / "output.json").string(); + std::string config = buildBatchConfig(fixtureName, outputPath); + std::string configPath = (tmpDir / "config.json").string(); + writeFile(configPath, config); + + auto result = runOLR(configPath); + ASSERT_EQ(result.exitCode, 0) << "OLR failed with output:\n" << result.output; + ASSERT_TRUE(fs::exists(outputPath)) << "Output file not created. OLR output:\n" << result.output; + + auto [baseDir, scenario] = parseFixtureName(fixtureName); + std::string expectedPath = (fs::path(TEST_DATA) / baseDir / "expected" / scenario / "output.json").string(); + std::string diff = compareGoldenFile(outputPath, expectedPath); + EXPECT_TRUE(diff.empty()) << "Golden file mismatch:\n" << diff; +} + +INSTANTIATE_TEST_SUITE_P( + Fixtures, + PipelineParamTest, + ::testing::ValuesIn(discoverFixtures()), + [](const ::testing::TestParamInfo& info) { + std::string name = info.param; + std::replace(name.begin(), name.end(), '-', '_'); + std::replace(name.begin(), name.end(), '/', '_'); + return name; + } +);