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;
+ }
+);