diff --git a/.config/nextest.toml b/.config/nextest.toml index 55ccfd291a..9257211108 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -50,6 +50,26 @@ slow-timeout = { period = "180s", terminate-after = 1 } filter = 'test(parity_function_first_class_callable_dispatch)' slow-timeout = { period = "180s", terminate-after = 1 } +# PDO_ODBC surface/live fixtures compile the full versioned PDO prelude and may +# also rebuild the optional system-client archive on a cold local cache. +[[profile.default.overrides]] +filter = 'test(pdo_odbc)' +slow-timeout = { period = "180s", terminate-after = 1 } + +[[profile.ci.overrides]] +filter = 'test(pdo_odbc)' +slow-timeout = { period = "180s", terminate-after = 1 } + +# PDO_OCI loads the full PDO surface and the cold optional ODPI-C archive build +# is appreciably slower on GitHub's shared runners. +[[profile.default.overrides]] +filter = 'test(pdo_oci)' +slow-timeout = { period = "300s", terminate-after = 1 } + +[[profile.ci.overrides]] +filter = 'test(pdo_oci)' +slow-timeout = { period = "300s", terminate-after = 1 } + # `lowers_examples_corpus` type-checks and lowers every program under examples/; # ~40s alone and past the global 60s cap on loaded runners. [[profile.default.overrides]] @@ -103,6 +123,30 @@ slow-timeout = { period = "180s", terminate-after = 1 } filter = 'test(test_eval_aot_callable_named_ref_arg_prep_fatal_cleans_up_stack)' slow-timeout = { period = "180s", terminate-after = 1 } +[[profile.default.overrides]] +filter = 'test(test_eval_aot_function_by_ref_arg_prep_fatal_cleans_up_stack)' +slow-timeout = { period = "180s", terminate-after = 1 } + +[[profile.ci.overrides]] +filter = 'test(test_eval_aot_function_by_ref_arg_prep_fatal_cleans_up_stack)' +slow-timeout = { period = "180s", terminate-after = 1 } + +[[profile.default.overrides]] +filter = 'test(test_eval_reflection_class_new_instance_rejects_aot_non_instantiable_class_likes)' +slow-timeout = { period = "180s", terminate-after = 1 } + +[[profile.ci.overrides]] +filter = 'test(test_eval_reflection_class_new_instance_rejects_aot_non_instantiable_class_likes)' +slow-timeout = { period = "180s", terminate-after = 1 } + +[[profile.default.overrides]] +filter = 'test(test_eval_reflection_class_new_instance_without_constructor_rejects_aot_non_classes)' +slow-timeout = { period = "180s", terminate-after = 1 } + +[[profile.ci.overrides]] +filter = 'test(test_eval_reflection_class_new_instance_without_constructor_rejects_aot_non_classes)' +slow-timeout = { period = "180s", terminate-after = 1 } + [[profile.default.overrides]] filter = 'test(test_eval_declared_method_return_type_values)' slow-timeout = { period = "180s", terminate-after = 1 } diff --git a/.github/docker/ci.Dockerfile b/.github/docker/ci.Dockerfile index c9689e7909..6edf81f28b 100644 --- a/.github/docker/ci.Dockerfile +++ b/.github/docker/ci.Dockerfile @@ -32,6 +32,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins ca-certificates \ curl \ file \ + freetds-dev \ git \ libbz2-dev \ libpcre2-dev \ @@ -39,6 +40,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins netbase \ pkg-config \ tzdata \ + unixodbc-dev \ zlib1g-dev \ zstd \ && rm -rf /var/lib/apt/lists/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1428e492a0..17301632c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,10 @@ concurrency: env: CARGO_TERM_COLOR: always + # Deep parser/lowering fixtures overflow Rust's small default worker stack on + # ARM64 runners. Keep one value for every archived nextest process so shard + # placement cannot change whether a test has enough stack. + RUST_MIN_STACK: "33554432" BRIDGE_CRATES: >- -p elephc-tls -p elephc-pdo @@ -52,8 +56,8 @@ jobs: - name: Install cargo-nextest uses: taiki-e/install-action@nextest - - name: Install test-only native providers - run: brew install pcre2 + - name: Install native test dependencies + run: brew install freetds pcre2 unixodbc - name: Check (no warnings) shell: bash @@ -62,6 +66,11 @@ jobs: cargo build 2>&1 | tee "$RUNNER_TEMP/cargo-build.log" ! grep -i warning "$RUNNER_TEMP/cargo-build.log" + - name: Build combined optional PDO driver profile + run: | + cargo build -p elephc-pdo --features dblib,firebird,odbc,informix,ibm,sqlsrv,oci,cubrid + cargo clean -p elephc-pdo + - name: Build bridge/native support crates # The codegen test runner links compiled programs against bridge # staticlibs: libelephc_tls.a (https:// / TLS), libelephc_pdo.a (PDO), @@ -126,6 +135,11 @@ jobs: cargo build 2>&1 | tee "$RUNNER_TEMP/cargo-build.log" ! grep -i warning "$RUNNER_TEMP/cargo-build.log" + - name: Build combined optional PDO driver profile + run: | + cargo build -p elephc-pdo --features dblib,firebird,odbc,informix,ibm,sqlsrv,oci,cubrid + cargo clean -p elephc-pdo + - name: Build bridge/native support crates # The codegen test runner links compiled programs against bridge # staticlibs: libelephc_tls.a (https:// / TLS), libelephc_pdo.a (PDO), @@ -185,6 +199,11 @@ jobs: cargo build 2>&1 | tee "$RUNNER_TEMP/cargo-build.log" ! grep -i warning "$RUNNER_TEMP/cargo-build.log" + - name: Build combined optional PDO driver profile + run: | + cargo build -p elephc-pdo --features dblib,firebird,odbc,informix,ibm,sqlsrv,oci,cubrid + cargo clean -p elephc-pdo + - name: Build bridge/native support crates # The codegen test runner links compiled programs against bridge # staticlibs: libelephc_tls.a (https:// / TLS), libelephc_pdo.a (PDO), @@ -429,7 +448,7 @@ jobs: cargo nextest run --profile ci \ --archive-file "$RUNNER_TEMP/nextest-archive.tar.zst" \ --workspace-remap . \ - -E 'binary(codegen_tests) and not test(~codegen::eval)' \ + -E 'binary(codegen_tests) and not test(~codegen::eval) and not test(~codegen::pdo)' \ --partition hash:${{ matrix.shard }}/16 \ --no-fail-fast --retries 1 --flaky-result pass \ -j 1 @@ -465,7 +484,7 @@ jobs: cargo nextest run --profile ci \ --archive-file "$RUNNER_TEMP/nextest-archive.tar.zst" \ --workspace-remap . \ - -E 'binary(codegen_tests) and not test(~codegen::eval)' \ + -E 'binary(codegen_tests) and not test(~codegen::eval) and not test(~codegen::pdo)' \ --partition hash:${{ matrix.shard }}/16 \ --no-fail-fast --retries 1 --flaky-result pass \ -j 1 @@ -501,11 +520,138 @@ jobs: cargo nextest run --profile ci \ --archive-file "$RUNNER_TEMP/nextest-archive.tar.zst" \ --workspace-remap . \ - -E 'binary(codegen_tests) and not test(~codegen::eval)' \ + -E 'binary(codegen_tests) and not test(~codegen::eval) and not test(~codegen::pdo)' \ --partition hash:${{ matrix.shard }}/16 \ --no-fail-fast --retries 1 --flaky-result pass \ -j 1 + # PDO fixtures all inject the same large standard-library prelude. Running a + # deterministic shard in one libtest process lets parser/runtime/bridge caches + # survive between cases while preserving the full three-target test surface. + pdo-codegen-tests-macos-aarch64: + name: PDO Codegen Tests (macos-aarch64 ${{ matrix.shard }}/4) + needs: build-archive-macos-aarch64 + runs-on: macos-14 + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v4 + + - name: Install cargo-nextest + uses: taiki-e/install-action@nextest + + - name: Install test-only native providers + run: brew install pcre2 + + - name: Download test archive + uses: actions/download-artifact@v4 + with: + name: nextest-archive-macos-aarch64 + path: ${{ runner.temp }} + + - name: Run PDO codegen shard in one process + env: + CARGO_NET_OFFLINE: "true" + ELEPHC_TEST_PREBUILT_BRIDGES: "1" + run: | + mkdir -p "$RUNNER_TEMP/nextest" + cargo nextest run --profile ci \ + --archive-file "$RUNNER_TEMP/nextest-archive.tar.zst" \ + --workspace-remap . \ + --extract-to "$RUNNER_TEMP/nextest" \ + --extract-overwrite --no-run \ + -E 'binary(codegen_tests)' + pdo_test_binary="$(find "$RUNNER_TEMP/nextest/target/debug/deps" \ + -type f -name 'codegen_tests-*' -perm -111 -print -quit)" + test -n "$pdo_test_binary" + bash scripts/ci/run_pdo_codegen_shard.sh \ + "$pdo_test_binary" "${{ matrix.shard }}" 4 + + pdo-codegen-tests-linux-x86_64: + name: PDO Codegen Tests (linux-x86_64 ${{ matrix.shard }}/4) + needs: build-archive-linux-x86_64 + runs-on: ubuntu-24.04 + timeout-minutes: 40 + container: + image: ghcr.io/illegalstudio/elephc-ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v4 + + - name: Download test archive + uses: actions/download-artifact@v4 + with: + name: nextest-archive-linux-x86_64 + path: ${{ runner.temp }} + + - name: Run PDO codegen shard in one process + env: + CARGO_NET_OFFLINE: "true" + ELEPHC_TEST_PREBUILT_BRIDGES: "1" + run: | + mkdir -p "$RUNNER_TEMP/nextest" + cargo nextest run --profile ci \ + --archive-file "$RUNNER_TEMP/nextest-archive.tar.zst" \ + --workspace-remap . \ + --extract-to "$RUNNER_TEMP/nextest" \ + --extract-overwrite --no-run \ + -E 'binary(codegen_tests)' + pdo_test_binary="$(find "$RUNNER_TEMP/nextest/target/debug/deps" \ + -type f -name 'codegen_tests-*' -perm -111 -print -quit)" + test -n "$pdo_test_binary" + bash scripts/ci/run_pdo_codegen_shard.sh \ + "$pdo_test_binary" "${{ matrix.shard }}" 4 + + pdo-codegen-tests-linux-aarch64: + name: PDO Codegen Tests (linux-aarch64 ${{ matrix.shard }}/4) + needs: build-archive-linux-aarch64 + runs-on: ubuntu-24.04-arm + timeout-minutes: 40 + container: + image: ghcr.io/illegalstudio/elephc-ci:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@v4 + + - name: Download test archive + uses: actions/download-artifact@v4 + with: + name: nextest-archive-linux-aarch64 + path: ${{ runner.temp }} + + - name: Run PDO codegen shard in one process + env: + CARGO_NET_OFFLINE: "true" + ELEPHC_TEST_PREBUILT_BRIDGES: "1" + run: | + mkdir -p "$RUNNER_TEMP/nextest" + cargo nextest run --profile ci \ + --archive-file "$RUNNER_TEMP/nextest-archive.tar.zst" \ + --workspace-remap . \ + --extract-to "$RUNNER_TEMP/nextest" \ + --extract-overwrite --no-run \ + -E 'binary(codegen_tests)' + pdo_test_binary="$(find "$RUNNER_TEMP/nextest/target/debug/deps" \ + -type f -name 'codegen_tests-*' -perm -111 -print -quit)" + test -n "$pdo_test_binary" + bash scripts/ci/run_pdo_codegen_shard.sh \ + "$pdo_test_binary" "${{ matrix.shard }}" 4 + # Eval integration tests compile, link, and run full programs while exercising # eval/magician paths, so keep them out of the ordinary codegen shards. Two # workers per shard reduce wall time without increasing the number of test runs. @@ -799,6 +945,9 @@ jobs: - codegen-tests-macos-aarch64 - codegen-tests-linux-x86_64 - codegen-tests-linux-aarch64 + - pdo-codegen-tests-macos-aarch64 + - pdo-codegen-tests-linux-x86_64 + - pdo-codegen-tests-linux-aarch64 - image-api-sync - builtins-docs-sync - managed-native-smoke @@ -818,6 +967,9 @@ jobs: test "${{ needs.codegen-tests-macos-aarch64.result }}" = "success" test "${{ needs.codegen-tests-linux-x86_64.result }}" = "success" test "${{ needs.codegen-tests-linux-aarch64.result }}" = "success" + test "${{ needs.pdo-codegen-tests-macos-aarch64.result }}" = "success" + test "${{ needs.pdo-codegen-tests-linux-x86_64.result }}" = "success" + test "${{ needs.pdo-codegen-tests-linux-aarch64.result }}" = "success" test "${{ needs.image-api-sync.result }}" = "success" test "${{ needs.builtins-docs-sync.result }}" = "success" test "${{ needs.managed-native-smoke.result }}" = "success" diff --git a/.github/workflows/pdo-live.yml b/.github/workflows/pdo-live.yml new file mode 100644 index 0000000000..0725ad43d0 --- /dev/null +++ b/.github/workflows/pdo-live.yml @@ -0,0 +1,492 @@ +name: PDO Live Databases + +# The live suites are ignored by the ordinary codegen matrix because they need +# database services and native client libraries. Keep them in the required PR +# loop, and split the embedded drivers from the optional system-client profiles +# so neither serial group holds the other on the workflow's critical path. +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +concurrency: + group: pdo-live-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + ELEPHC_PDO_LIVE_REQUIRED: '1' + +jobs: + pdo-core: + name: PDO core live suites (PostgreSQL 16 + MySQL 8.4) + runs-on: ubuntu-24.04 + timeout-minutes: 75 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: testdb + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U test -d testdb" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: testdb + MYSQL_USER: test + MYSQL_PASSWORD: test + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -utest -ptest --silent" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + + env: + # The in-crate bridge tests deliberately exercise their fallback from the + # legacy *_TEST_DSN variables to these codegen-facing names. + ELEPHC_PG_DSN: 'pgsql:host=127.0.0.1;port=5432;dbname=testdb;user=test;password=test;sslmode=disable' + ELEPHC_MY_DSN: 'mysql:host=127.0.0.1;port=3306;dbname=testdb;user=test;password=test' + ELEPHC_MY_TLS_DSN: 'mysql:host=127.0.0.1;port=3306;dbname=testdb;user=test;password=test' + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build state + uses: actions/cache@v4 + with: + path: | + ~/.cargo/git + ~/.cargo/registry + target + key: rust-pdo-live-core-${{ hashFiles('Cargo.lock') }} + restore-keys: | + rust-pdo-live-core- + rust-pdo-live- + + - name: Install native test dependencies + run: | + sudo apt-get update + sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y \ + binutils \ + build-essential \ + file \ + krb5-user \ + libbz2-dev \ + libpcre2-dev \ + libpq-dev \ + libssl-dev \ + pkg-config \ + tzdata \ + zlib1g-dev + + - name: Configure the MySQL live-test server + run: | + docker exec ${{ job.services.mysql.id }} \ + mysql -uroot -proot -e " + SET GLOBAL local_infile = ON; + GRANT ALL PRIVILEGES ON testdb.* TO 'test'@'%'; + GRANT CREATE ROUTINE, ALTER ROUTINE, EXECUTE ON testdb.* TO 'test'@'%'; + FLUSH PRIVILEGES;" + + - name: Configure TLS on both live-test servers + run: | + docker exec ${{ job.services.mysql.id }} sh -ec ' + openssl req -new -x509 -nodes -days 1 \ + -subj "/CN=elephc-pdo-mysql-test-ca" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -keyout /var/lib/mysql/ca-key.pem \ + -out /var/lib/mysql/ca.pem + openssl req -new -nodes \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \ + -keyout /var/lib/mysql/server-key.pem \ + -out /var/lib/mysql/server.csr + printf "subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\n" \ + > /var/lib/mysql/server.ext + openssl x509 -req -days 1 -sha256 \ + -in /var/lib/mysql/server.csr \ + -CA /var/lib/mysql/ca.pem \ + -CAkey /var/lib/mysql/ca-key.pem \ + -CAcreateserial \ + -extfile /var/lib/mysql/server.ext \ + -out /var/lib/mysql/server-cert.pem + chown mysql:mysql \ + /var/lib/mysql/ca-key.pem \ + /var/lib/mysql/ca.pem \ + /var/lib/mysql/server-key.pem \ + /var/lib/mysql/server-cert.pem + chmod 600 /var/lib/mysql/ca-key.pem /var/lib/mysql/server-key.pem' + docker exec ${{ job.services.mysql.id }} \ + mysql -uroot -proot -e "ALTER INSTANCE RELOAD TLS;" + docker cp ${{ job.services.mysql.id }}:/var/lib/mysql/ca.pem "$RUNNER_TEMP/mysql-ca.pem" + docker cp ${{ job.services.mysql.id }}:/var/lib/mysql/public_key.pem "$RUNNER_TEMP/mysql-public-key.pem" + echo "ELEPHC_MY_TLS_CA=$RUNNER_TEMP/mysql-ca.pem" >> "$GITHUB_ENV" + echo "ELEPHC_MY_SERVER_PUBLIC_KEY=$RUNNER_TEMP/mysql-public-key.pem" >> "$GITHUB_ENV" + mkdir -p "$RUNNER_TEMP/mysql-ca-dir" + cp "$RUNNER_TEMP/mysql-ca.pem" "$RUNNER_TEMP/mysql-ca-dir/ca.pem" + echo "ELEPHC_MY_TLS_CAPATH=$RUNNER_TEMP/mysql-ca-dir" >> "$GITHUB_ENV" + + docker exec ${{ job.services.postgres.id }} sh -ec ' + openssl req -new -x509 -nodes -days 1 \ + -subj "/CN=elephc-pdo-test-ca" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -keyout /var/lib/postgresql/data/root.key \ + -out /var/lib/postgresql/data/root.crt + openssl req -new -nodes \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \ + -keyout /var/lib/postgresql/data/server.key \ + -out /var/lib/postgresql/data/server.csr + printf "subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\n" \ + > /var/lib/postgresql/data/server.ext + openssl x509 -req -days 1 -sha256 \ + -in /var/lib/postgresql/data/server.csr \ + -CA /var/lib/postgresql/data/root.crt \ + -CAkey /var/lib/postgresql/data/root.key \ + -CAcreateserial \ + -extfile /var/lib/postgresql/data/server.ext \ + -out /var/lib/postgresql/data/server.crt + chown postgres:postgres \ + /var/lib/postgresql/data/server.crt \ + /var/lib/postgresql/data/server.key + chmod 600 /var/lib/postgresql/data/server.key' + docker cp ${{ job.services.postgres.id }}:/var/lib/postgresql/data/root.crt \ + "$RUNNER_TEMP/postgres-ca.pem" + echo "ELEPHC_PG_TLS_DSN=pgsql:host=localhost;port=5432;dbname=testdb;user=test;password=test;sslmode=verify-full;sslrootcert=$RUNNER_TEMP/postgres-ca.pem" \ + >> "$GITHUB_ENV" + docker exec ${{ job.services.postgres.id }} \ + psql -U test -d testdb -c "ALTER SYSTEM SET ssl = 'on';" + docker restart ${{ job.services.postgres.id }} + for attempt in $(seq 1 30); do + if docker exec ${{ job.services.postgres.id }} pg_isready -U test -d testdb; then + exit 0 + fi + sleep 2 + done + docker logs ${{ job.services.postgres.id }} + exit 1 + + - name: Build the native PDO bridge staticlib + run: cargo build -p elephc-pdo + + - name: Live native bridge tests + run: cargo test -p elephc-pdo -- --ignored + + - name: Live native PostgreSQL codegen tests + run: cargo test --test codegen_tests -- --ignored pgsql --test-threads=4 + + - name: Build the libpq PDO bridge staticlib + run: cargo build -p elephc-pdo --features mysql-tls,libpq-gss + + - name: Libpq bridge unit tests + run: cargo test -p elephc-pdo --features mysql-tls,libpq-gss --lib + + - name: Live libpq bridge tests + run: cargo test -p elephc-pdo --features mysql-tls,libpq-gss -- --ignored + + - name: Live libpq PostgreSQL codegen tests + env: + ELEPHC_PDO_LIBPQ: '1' + run: cargo test --features pdo-libpq-gss --test codegen_tests -- --ignored pgsql --test-threads=4 + + - name: Live libpq GSSAPI handshake + run: ./scripts/test-pdo-gss.sh + + - name: Live MySQL codegen tests + env: + ELEPHC_PDO_LIBPQ: '1' + run: cargo test --features pdo-libpq-gss --test codegen_tests -- --ignored mysql --test-threads=4 + + pdo-drivers: + name: PDO optional-driver live suites + runs-on: ubuntu-24.04 + timeout-minutes: 75 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: testdb + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U test -d testdb" + --health-interval 5s + --health-timeout 5s + --health-retries 20 + + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + ACCEPT_EULA: Y + MSSQL_SA_PASSWORD: Elephc_Pdo!2026_Test + ports: + - 1433:1433 + options: >- + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'Elephc_Pdo!2026_Test' -Q 'SELECT 1'" + --health-interval 5s + --health-timeout 10s + --health-retries 30 + + firebird: + image: firebirdsql/firebird:5.0.4-trixie + env: + FIREBIRD_ROOT_PASSWORD: Elephc_Firebird!2026_Test + FIREBIRD_DATABASE: elephc.fdb + FIREBIRD_DATABASE_DEFAULT_CHARSET: UTF8 + ports: + - 3050:3050 + options: >- + --health-cmd "bash -c '- + --health-cmd "bash -lc 'gosu cubrid csql -u dba -C cubdb -c \"SELECT 1;\"'" + --health-interval 5s + --health-timeout 10s + --health-retries 30 + + oracle: + image: gvenzl/oracle-free:23-slim-faststart + env: + ORACLE_PASSWORD: Elephc_Oracle!2026_Test + APP_USER: test + APP_USER_PASSWORD: test + ports: + - 1521:1521 + options: >- + --health-cmd healthcheck.sh + --health-interval 10s + --health-timeout 5s + --health-retries 30 + + env: + ELEPHC_DBLIB_DSN: 'dblib:host=127.0.0.1;port=1433;user=sa;password=Elephc_Pdo!2026_Test;charset=UTF-8' + ELEPHC_FIREBIRD_DSN: 'firebird:dbname=127.0.0.1/3050:/var/lib/firebird/data/elephc.fdb;charset=UTF8;user=SYSDBA;password=Elephc_Firebird!2026_Test' + ELEPHC_ODBC_DSN: 'odbc:Driver={PostgreSQL Unicode};Servername=127.0.0.1;Port=5432;Database=testdb;UID=test;PWD=test' + ELEPHC_SQLSRV_DSN: 'sqlsrv:Server=127.0.0.1,1433;Database=master;Encrypt=no;TrustServerCertificate=yes;user=sa;password=Elephc_Pdo!2026_Test' + ELEPHC_OCI_DSN: 'oci:dbname=//127.0.0.1:1521/FREEPDB1;charset=AL32UTF8;user=test;password=test' + ELEPHC_CUBRID_DSN: 'cubrid:host=127.0.0.1;port=33000;dbname=cubdb' + + steps: + - uses: actions/checkout@v4 + + - name: Install Oracle Instant Client 23.26 Basic Light + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libaio1t64 unzip + curl --fail --location --retry 5 --retry-all-errors \ + --output "$RUNNER_TEMP/instantclient-basiclite.zip" \ + https://download.oracle.com/otn_software/linux/instantclient/2326200v2/instantclient-basiclite-linux.x64-23.26.2.0.0.zip + test "$(cksum "$RUNNER_TEMP/instantclient-basiclite.zip" | awk '{print $1 " " $2}')" = '149323083 75797020' + unzip -q "$RUNNER_TEMP/instantclient-basiclite.zip" -d "$RUNNER_TEMP" + mkdir -p "$RUNNER_TEMP/oracle-client" + cp -a "$RUNNER_TEMP/instantclient_23_26/." "$RUNNER_TEMP/oracle-client/" + test -e "$RUNNER_TEMP/oracle-client/libclntsh.so" + libaio_path="$(find /usr/lib -name 'libaio.so.1t64' -print -quit)" + test -n "$libaio_path" + ln -s "$libaio_path" "$RUNNER_TEMP/oracle-client/libaio.so.1" + echo "LD_LIBRARY_PATH=$RUNNER_TEMP/oracle-client" >> "$GITHUB_ENV" + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build state + uses: actions/cache@v4 + with: + path: | + ~/.cargo/git + ~/.cargo/registry + target + key: rust-pdo-live-drivers-${{ hashFiles('Cargo.lock') }} + restore-keys: | + rust-pdo-live-drivers- + rust-pdo-live- + + - name: Install native test dependencies + run: | + sudo apt-get update + sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y \ + binutils \ + build-essential \ + file \ + freetds-dev \ + libaio1t64 \ + libbz2-dev \ + libpcre2-dev \ + libpq-dev \ + libssl-dev \ + odbc-postgresql \ + pkg-config \ + tzdata \ + unixodbc-dev \ + zlib1g-dev + + - name: Install Microsoft ODBC Driver 18 for SQL Server + shell: bash + run: | + curl -fsSLo "$RUNNER_TEMP/packages-microsoft-prod.deb" https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb + sudo dpkg -i "$RUNNER_TEMP/packages-microsoft-prod.deb" + sudo apt-get update + sudo env ACCEPT_EULA=Y DEBIAN_FRONTEND=noninteractive apt-get install -y msodbcsql18 + + - name: Build the PDO_SQLSRV 5.13.1 profile + run: cargo build -p elephc-pdo --features sqlsrv + + - name: PDO_SQLSRV bridge unit tests + run: 'cargo test -p elephc-pdo --features sqlsrv odbc::tests:: -- --skip live_odbc_round_trip --skip live_sqlsrv_round_trip' + + - name: Live PDO_SQLSRV bridge test + run: 'cargo test -p elephc-pdo --features sqlsrv odbc::tests::live_sqlsrv_round_trip -- --ignored --test-threads=1' + + - name: Live PDO_SQLSRV codegen tests + env: + ELEPHC_PDO_SQLSRV: '1' + run: cargo test --features pdo-sqlsrv --test codegen_tests -- --ignored pdo_sqlsrv --test-threads=1 + + - name: PDO_SQLSRV compiled surface tests + env: + ELEPHC_PDO_SQLSRV: '1' + run: cargo test --features pdo-sqlsrv --test codegen_tests pdo_sqlsrv -- --test-threads=1 + + - name: Build the PDO_OCI bridge staticlib + run: cargo build -p elephc-pdo --features oci + + - name: PDO_OCI bridge unit tests + run: 'cargo test -p elephc-pdo --features oci oci::tests:: -- --skip live_oci_round_trip' + + - name: Live PDO_OCI bridge test + run: 'cargo test -p elephc-pdo --features oci oci::tests::live_oci_round_trip -- --ignored --test-threads=1' + + - name: Live PDO_OCI codegen tests + env: + ELEPHC_PDO_OCI: '1' + run: cargo test --features pdo-oci --test codegen_tests -- --ignored pdo_oci --test-threads=1 + + - name: PDO_OCI compiled surface tests + env: + ELEPHC_PDO_OCI: '1' + run: cargo test --features pdo-oci --test codegen_tests pdo_oci_surface -- --test-threads=1 + + - name: Install the CUBRID CCI client from the service image + run: | + docker cp '${{ job.services.cubrid.id }}:/home/cubrid/CUBRID/cci/lib/libcascci.so.11.2' "$RUNNER_TEMP/libcascci.so.11.2" + ln -s libcascci.so.11.2 "$RUNNER_TEMP/libcascci.so" + + - name: PDO_CUBRID bridge unit tests + run: 'cargo test -p elephc-pdo --features cubrid cubrid::tests::' + + - name: PDO_CUBRID compiled surface tests + env: + ELEPHC_PDO_CUBRID: '1' + run: cargo test --features pdo-cubrid --test codegen_tests pdo_cubrid_surface -- --test-threads=1 + + - name: Live PDO_CUBRID codegen test + env: + ELEPHC_PDO_CUBRID: '1' + CUBRID_CCI_LIBRARY: '${{ runner.temp }}/libcascci.so.11.2' + run: cargo test --features pdo-cubrid --test codegen_tests -- --ignored pdo_cubrid --test-threads=1 + + - name: Build the PDO_DBLIB bridge staticlib + run: cargo build -p elephc-pdo --features dblib + + - name: Live PDO_DBLIB bridge tests + run: 'cargo test -p elephc-pdo --features dblib dblib::tests:: -- --ignored --test-threads=1' + + - name: Live PDO_DBLIB codegen tests + env: + ELEPHC_PDO_DBLIB: '1' + run: cargo test --features pdo-dblib --test codegen_tests -- --ignored pdo_dblib --test-threads=1 + + - name: PDO_DBLIB compiled surface tests + env: + ELEPHC_PDO_DBLIB: '1' + run: cargo test --features pdo-dblib --test codegen_tests pdo_dblib_surface -- --test-threads=1 + + - name: Build the PDO_FIREBIRD bridge staticlib + run: cargo build -p elephc-pdo --features firebird + + - name: Live PDO_FIREBIRD bridge tests + run: 'cargo test -p elephc-pdo --features firebird firebird::tests:: -- --ignored --test-threads=1' + + - name: Live PDO_FIREBIRD codegen tests + env: + ELEPHC_PDO_FIREBIRD: '1' + run: cargo test --features pdo-firebird --test codegen_tests -- --ignored pdo_firebird --test-threads=1 + + - name: PDO_FIREBIRD compiled surface tests + env: + ELEPHC_PDO_FIREBIRD: '1' + run: cargo test --features pdo-firebird --test codegen_tests pdo_firebird_surface -- --test-threads=1 + + - name: Build the PDO_ODBC bridge staticlib + run: cargo build -p elephc-pdo --features odbc + + - name: PDO_ODBC bridge unit tests + run: 'cargo test -p elephc-pdo --features odbc odbc::tests::' + + - name: Live PDO_ODBC bridge test + run: 'cargo test -p elephc-pdo --features odbc odbc::tests::live_odbc_round_trip -- --ignored --test-threads=1' + + - name: Live PDO_ODBC codegen tests + env: + ELEPHC_PDO_ODBC: '1' + run: cargo test --features pdo-odbc --test codegen_tests -- --ignored pdo_odbc --test-threads=1 + + - name: PDO_ODBC compiled surface tests + env: + ELEPHC_PDO_ODBC: '1' + run: cargo test --features pdo-odbc --test codegen_tests pdo_odbc_surface -- --test-threads=1 + + - name: Build the PDO_INFORMIX Client SDK profile + run: cargo build -p elephc-pdo --features informix + + - name: PDO_INFORMIX bridge unit tests + run: 'cargo test -p elephc-pdo --features informix odbc::tests::' + + - name: PDO_INFORMIX compiled surface tests + env: + ELEPHC_PDO_INFORMIX: '1' + run: cargo test --features pdo-informix --test codegen_tests pdo_informix -- --test-threads=1 + + - name: Build the PDO_IBM 1.7.0 CLI profile + run: cargo build -p elephc-pdo --features ibm + + - name: PDO_IBM bridge unit tests + run: 'cargo test -p elephc-pdo --features ibm odbc::tests::' + + - name: PDO_IBM compiled surface tests + env: + ELEPHC_PDO_IBM: '1' + run: cargo test --features pdo-ibm --test codegen_tests pdo_ibm -- --test-threads=1 diff --git a/.plans/pdo-full-maintained-php-parity.md b/.plans/pdo-full-maintained-php-parity.md new file mode 100644 index 0000000000..0267244f93 --- /dev/null +++ b/.plans/pdo-full-maintained-php-parity.md @@ -0,0 +1,118 @@ +# PDO full maintained-PHP parity + +- [x] Freeze the PHP 8.2–8.5 PDO reference matrix from versioned php-src/PECL sources. +- [x] Replace availability and DSN dispatch conditionals with a single compiled-driver registry. +- [ ] Migrate driver-specific attributes, subclasses, statements, and capability hooks to the registry. +- [x] Implement `pdo.dsn.*` alias resolution with PHP-compatible configuration precedence. +- [ ] Remove the documented common PDO/PDOStatement divergences. +- [x] Add PDO_DBLIB / `Pdo\Dblib` with versioned constants and FreeTDS live tests. +- [x] Add PDO_FIREBIRD / `Pdo\Firebird` with versioned constants and Firebird live tests. +- [x] Add PDO_ODBC / `Pdo\Odbc` with unixODBC/iODBC live tests. +- [x] Add PDO_OCI compatibility for PHP 8.2–8.5, including the post-8.3 PECL split. +- [x] Add the maintained external PDO_CUBRID, PDO_IBM, PDO_INFORMIX, and PDO_SQLSRV surfaces. +- [ ] Validate every available backend on macOS AArch64, Linux AArch64, and Linux x86_64. +- [ ] Regenerate the complete documentation/compatibility report and close every recorded gap. + +Current qualification boundary: all eleven drivers in the frozen matrix are implemented, +and every optional profile builds in the three-target CI matrix. PostgreSQL, MySQL, +DBLIB, Firebird, ODBC, SQLSRV, OCI, and CUBRID have Linux live acceptance; Informix and +IBM retain unit and compiled-surface coverage until redistributable proprietary Client SDK +and server fixtures are available. Cross-target live execution and the documented common +PDO divergences therefore remain open and prevent a literal 100% certification claim. + +## Normative scope + +The normative language versions are the PHP branches currently supported on 2026-07-16: +PHP 8.2, 8.3, 8.4, and 8.5. PHP 8.6 remains an elephc preview target and inherits the +latest known surface until its php-src branch becomes stable. PHP 8.0/8.1 stay supported +as historical elephc targets but do not drive new compatibility decisions. + +The in-tree php-src drivers are `pdo_dblib`, `pdo_firebird`, `pdo_mysql`, `pdo_odbc`, +`pdo_pgsql`, and `pdo_sqlite`; `pdo_oci` is in php-src through PHP 8.3 and is maintained +externally afterwards. The PHP manual also lists PDO_CUBRID, PDO_IBM, PDO_INFORMIX, and +PDO_SQLSRV. Those external drivers are in scope: their upstream extension sources and +released binaries, not generic PDO behavior alone, define their driver-specific contract. + +## Frozen upstream matrix (2026-07-16) + +| Driver | PHP 8.2 | PHP 8.3 | PHP 8.4 | PHP 8.5 | Native client/reference | +| --- | --- | --- | --- | --- | --- | +| mysql | php-src | php-src | php-src | php-src | mysqlnd behavior; Rust wire client must match it | +| pgsql | php-src | php-src | php-src | php-src | libpq behavior; `libpq-gss` for GSS paths | +| sqlite | php-src | php-src | php-src | php-src | SQLite C API | +| dblib | php-src legacy class | php-src legacy class | `Pdo\Dblib` + aliases | class + deprecated aliases | FreeTDS DB-Library (`libsybdb`) | +| firebird | php-src legacy class | php-src legacy class | `Pdo\Firebird` + aliases | class + deprecated aliases | Firebird client (`fbclient`) | +| odbc | php-src | php-src | php-src | php-src | unixODBC/iODBC + selected ODBC driver | +| oci | bundled php-src | bundled php-src / PECL transition | PECL | PECL | Oracle Instant Client OCI | +| sqlsrv | Microsoft 5.12 line | Microsoft 5.12 line | Microsoft 5.12+ | Microsoft 5.13+ | Microsoft ODBC Driver 17/18 | +| ibm | PECL | PECL | `Pdo\Ibm` in PECL 1.7 | class + deprecated aliases | IBM CLI/ODBC | +| informix | PECL | PECL | PECL | PECL | Informix CSDK CLI | +| cubrid | external CUBRID extension | external CUBRID extension | external CUBRID extension | external CUBRID extension | CUBRID CCI | + +Normative external sources are the maintained upstream repositories/releases: + +- PDO_OCI: +- PDO_SQLSRV: +- PDO_IBM: +- PDO_INFORMIX: +- PDO_CUBRID: + +The word “legacy class” means the driver is usable through `PDO`, while its namespaced +`Pdo\` subclass has not yet been introduced by that PHP/extension version. Old +driver constants remain available on `PDO`; where PHP 8.5 moves them to a namespaced +class, the aliases remain present with the same deprecation behavior. + +## Compatibility contract + +For every driver and PHP target, parity covers: + +- DSN grammar, credential precedence, connection/persistence behavior, and error shape; +- constants, `Pdo\*` subclasses, method signatures, attributes, and availability by version; +- placeholder parsing, native/emulated prepares, binds, LOBs, rowsets, metadata, and types; +- transaction/autocommit behavior, quoting, timeout semantics, and driver-native errors; +- build-time and runtime client-library version boundaries; +- `PDO::getAvailableDrivers()` / `pdo_drivers()` reflecting the actually linked drivers; +- positive live-server tests and negative security/failure tests. + +No option may be accepted inertly. A client feature that cannot be honored must fail with +the same PHP-visible diagnostic as its reference driver. Optional proprietary clients must +be isolated behind bridge features, retain explicit diagnostics when unavailable, and must +not weaken the first-class supported-target policy for the default build. + +## Architecture + +The current monolithic `Conn`/`Stmt` enums and PHP string comparisons are replaced +incrementally by a single registry describing each driver name, DSN prefixes/aliases, +version availability, library feature, attributes, subclasses, and capability hooks. The +existing SQLite/PostgreSQL/MySQL implementations migrate first without semantic changes; +new drivers then plug into that boundary rather than expanding scattered match trees. + +System-client drivers remain optional bridge profiles. Prefer protocol-native Rust clients +when they reproduce the PHP client's semantics on every target; otherwise call the same C +client as PHP (as the libpq GSS profile does). CI must build both the standalone default and +each system-client profile. + +## `pdo.dsn.*` aliases + +Aliases are runtime configuration in PHP, so compile-time substitution is insufficient. +The bridge will resolve a colonless PDO DSN from PHP-style configuration sources before +driver dispatch. The implementation must preserve PHP's distinction between an undefined +alias (`invalid data source name`) and a resolved alias whose driver is unavailable +(`could not find driver`), credential precedence, persistent-pool keys, and `uri:` handling. + +Configuration discovery and precedence will be shared with the compiler's future INI +surface, but the PDO implementation must at minimum honor `PHPRC`, the loaded `php.ini`, +scan-directory fragments, and `pdo.dsn.` last-assignment semantics. Tests use isolated +temporary configurations and never depend on a developer machine's PHP installation. + +## Delivery order + +1. Registry + aliases + common divergences, because every subsequent driver depends on it. +2. DBLIB and Firebird, both bundled and independently runnable in Linux CI. +3. ODBC as the shared system-client substrate. +4. OCI and the externally maintained drivers, with hermetic CI where redistribution permits. +5. Cross-version/source audit and complete supported-target verification. + +Each phase lands green independently with examples and live CI. “100%” is claimed only when +the generated audit contains no unexplained missing symbol, option, version gate, diagnostic, +or unexecuted live path. diff --git a/CHANGELOG.md b/CHANGELOG.md index 90faf66da6..37534b83d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Releases are listed newest first. ## [Unreleased] - Added PHP-compatible `parse_url()` to native compilation and the Magician `eval()` runtime, including associative and component-selector return shapes, `PHP_URL_*` constants, IPv6/userinfo/port edge cases, catchable invalid-selector `ValueError`s, and identical support on macOS ARM64, Linux ARM64, and Linux x86_64. - Fixed by-reference `foreach` over indexed and associative array elements silently discarding mutations or stopping early when the parent was replaced (issue #580). +- Fixed the PDO_OCI live CI profile loading a hand-selected subset of an Oracle Database server's Oracle Home, which could enter the client and segfault during connection initialization. The profile now installs Oracle's complete, pinned Instant Client 23.26 Basic Light archive, verifies its published size and checksum, and supplies Ubuntu's versioned `libaio` compatibility name before exercising both the Rust bridge and compiled PHP surface. +- Fixed repeated PDO_SQLSRV prepares with statement options crashing in `isset()` when a Mixed-valued associative hash stored a concrete per-entry scalar tag. Hash `isset()` now distinguishes concrete values, concrete nulls, and already-boxed Mixed cells before unboxing, so the PDO prelude can keep applying attributes 1000–1009 through its PHP-compatible `foreach` path. +- Fixed PDO_SQLSRV retaining a native scrollable ODBC cursor after `PDO::CURSOR_SCROLL` was combined with `PDO::SQLSRV_CURSOR_BUFFERED`, which could crash while materializing typed result columns. The prepare path now follows Microsoft’s driver exactly: PDO scroll mode starts as `SQL_CURSOR_STATIC`, then client-buffered mode restores `SQL_CURSOR_FORWARD_ONLY` while retaining buffered fetch semantics in the bridge. +- Fixed PDO_SQLSRV relying on `SQLDescribeParam` for ordinary parameters, which Microsoft ODBC can report as legacy `TEXT` when a prepared statement targets a temporary table. This made otherwise valid string values fail against `DATETIME2` and other non-text columns. Ordinary SQLSRV binds now follow the official Microsoft PHP driver by deriving `VARCHAR`/`WVARCHAR`/`VARBINARY`, integer, floating-point, and NULL defaults from the PHP value plus statement encoding; native parameter description remains an external Always Encrypted concern. +- Fixed PDO_SQLSRV binding PHP floating-point values as `SQL_C_CHAR`/long text, which made Microsoft ODBC reject parameters targeting `DECIMAL`/`NUMERIC` columns. SQLSRV now follows Microsoft's PHP driver by retaining an aligned native `double` through execution, binding it as `SQL_C_DOUBLE`, preserving numeric descriptors, and falling back to `SQL_FLOAT` instead of text when the driver cannot describe the parameter. +- Fixed PDO_SQLSRV attempting `SQLFetch` after a successful zero-column INSERT/UPDATE/DDL result, which Microsoft ODBC Driver 17/18 rejects with "Invalid cursor state". CLI-backed PDO statements now record `SQLRowCount` directly when no result columns exist and fetch only real rowsets. +- Fixed PDO_ODBC, PDO_DBLIB, PDO_FIREBIRD, and PDO_SQLSRV placeholder parsing incorrectly applying MySQL's `#` line-comment rule. These drivers now follow php-src's generic PDO scanner, so SQL Server temporary-table identifiers such as `#events` do not hide following named parameters while MySQL keeps its driver-specific `#` comments. +- Fixed concurrent PDO column reads sharing one bridge return buffer: byte-counted text and BLOB results are now isolated per thread, so a PostgreSQL, MySQL, or other driver read cannot invalidate another thread's pending result before elephc copies it into owned PHP storage. +- Fixed generated/AOT calls made by `eval()` losing scalar, `mixed`, iterable, constructor, reflection, and callable by-reference writeback after ordinary boxed-Mixed reads gained detached PHP value semantics. The eval runtime now uses an explicit owned shared-cell read for its internal variable handles, while ordinary PHP reads remain detached and nested-write reads keep their COW-specific path. +- Fixed symbolic defaults for object-typed parameters: enum-case defaults are now resolved and type-checked semantically after class schemas are complete across functions, methods and constructors, constructor-promoted properties, and closures, including named, `self::`, `static::`, and `parent::` receivers. Missing cases and scalar class constants are rejected instead of slipping through syntactic typing, while `ReflectionParameter::getDefaultValueConstantName()` preserves the source-level constant spelling. +- Added PHP-compatible `::class` support on object expressions: `$object::class` now returns the receiver's concrete runtime class name, evaluates the receiver exactly once, and rejects statically known non-object receivers, while existing named and static forms remain unchanged. +- Added support for `static $x;` function-static declarations without an initializer, in both the native parser and the Magician `eval()` parser: the missing initializer desugars to `= null`, matching PHP, where `static $x;` and `static $x = null;` are identical (including `isset()` behavior). +- Fixed untyped properties (instance and static) initializing to their inferred type's zero value instead of PHP's implicit `null`: `public $x;` and `public $x = null;` now read as `NULL` before the first write, `is_null()` / `=== null` observe it, and later scalar assignments keep nullable storage (the same slot layout as a typed `?T` property) instead of failing to compile (`prop_set assigning PHP type Void ...`) or crashing `var_dump()` on null array slots. Heterogeneous assignments widen the slot to `mixed`; assignments inside the class's own constructor keep the historical precise inferred type, and untyped properties with concrete defaults are unchanged. `ReflectionClass::getDefaultProperties()` and `ReflectionProperty::getDefaultValue()` now see the implicit `null` default through the same schema. +- Added the `--strict-php` flag: the compiler accepts only PHP-compatible constructs. Extension syntax (`ifdef`, `packed class`, `extern`, `ptr_cast`, `buffer_new`, typed local declarations, `ptr`/`buffer` annotations) is rejected at compile time with per-violation diagnostics across the main file, includes, and autoloaded files, while extension builtins (`ptr_*`, `zval_*`, `buffer_*`, `class_attribute_*`) behave exactly as under the PHP interpreter — `function_exists()` reports `false`, calling one is an undefined function with a hint naming the disabled extension, and user code may declare its own functions with those names. Strict mode also reaches `eval()` with PHP's execute-time semantics: extension builtins do not exist inside eval'd fragments (runtime fatal on call, coherent `function_exists`/`is_callable`), extension syntax in a fragment is a runtime parse error, and user functions shadowing extension names stay callable. Programs using compiler preludes (PDO, timezone, image, web) keep compiling; `--define` cannot be combined with the flag. ## [0.26.3] - 2026-08-05 - Added tagless `.lfc` source files with per-file PHP/LFC classification across entry points, includes, and autoload; LFC always enables elephc extensions, while `--strict-php` remains PHP-only and now composes with `--define`, callable dispatch, and `eval()`. @@ -91,7 +105,7 @@ Releases are listed newest first. - Fixed `--web --max-requests` crash-loop accounting (issue #516): workers that exit after a planned request-quota recycle now use a dedicated status that the master excludes from startup-death streaks, so sustained traffic with a low quota keeps respawning workers instead of shutting down the server; genuine setup failures, other exit codes, and signal deaths still feed the guard. - Fixed nullable chained array reads (issue #525): consuming a value from a `?array` receiver now releases the one-shot hidden owned Mixed temporary created by nullable access, on both null and non-null paths, without invalidating the extracted result or double-releasing repeated reads. - Fixed chained container reads after a missing outer array offset (issues #526 and #554): indexed, associative, mixed-key, truthiness, `empty()`, `(bool)`, `count()`, spread-length, object-property, and method-call consumers now recognize null-container receivers before dereferencing them, preventing segfaults across every supported target. Direct nested reads emit PHP's missing-key and null-offset diagnostics, associative misses warn consistently, and invalid `count()`, spread, and method operations raise catchable PHP errors without evaluating skipped method arguments; `isset()`, `empty()`, and null coalescing remain silent across the full subscript chain, string-valued misses retain their null marker so `??` selects its default without conflating real empty strings, and `foreach` skips a missing container instead of crashing. -- Fixed nested writes into an `Array(Mixed)` element (issue #529): `$a[$i][$j] = ...` no longer mutates a detached Mixed cell from the read path and silently drops the update. EIR now splits off the innermost key and writes through the parent via `__rt_mixed_array_set` (or `offsetSet` for `ArrayAccess` object parents), so homogeneous string/int inner slots, boxed Mixed inners, associative keys, compound assigns, and object parents all persist, with heap-debug remaining clean. +- Fixed nested writes into mixed-valued containers (issue #529): `$a[$i][$j] = ...` no longer mutates a detached Mixed cell from the read path and silently drops the update. EIR now splits off the innermost key, fetches the parent through an explicit write-only read that preserves stored-cell identity (promoting typed heterogeneous-hash entries to boxed storage when required), and writes through it via `__rt_mixed_array_set` or `offsetSet`. Indexed and associative outer containers, dynamically typed receivers, homogeneous string/int inner slots, boxed Mixed inners, associative keys, compound assigns, function returns, and object parents now persist on every supported target, while ordinary reads retain independent PHP value semantics and resource reads retain their shared stream/destructor lifetime. - Fixed owned boxed Mixed temporaries leaking after string conversion (issue #527): explicit `(string)` casts plus implicit concatenation and interpolation now release detached Mixed sources, including by-value `foreach` element reads and non-null unions represented as Mixed; borrowed, persistent, moved, and non-heap values remain release no-ops. - Fixed boxed Mixed values leaking when nested loops reinitialize a local whose storage widens after lowering (issue #534): EIR now records a deferred `ReleaseLocalSlot` before the overwrite, prunes it for final scalar or ref-bound slots, and lowers it using the final slot representation. Cleanup is flow-sensitive across conditional ref-cell promotion, aliases, by-reference `foreach` and pointer paths, and widened parameters, preventing both missed releases and double frees with the EIR optimizer enabled or disabled. - Fixed function- and method-local indexed arrays leaking previous COW hash generations after promotion to string-keyed associative storage (issue #538): lowering now releases the boxed Mixed slot owner before consuming mutations and finalizes provisional concrete-load releases against the slot's final storage type. This preserves real COW aliases while avoiding an artificial clone on every insertion, leaving heap-debug clean with the EIR optimizer enabled or disabled across every supported target. diff --git a/Cargo.lock b/Cargo.lock index 688952facb..226801e529 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,12 +28,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -93,12 +87,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -113,20 +101,24 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bindgen" -version = "0.72.1" +version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +checksum = "c4243e6031260db77ede97ad86c27e501d646a27ab57b59a574f725d98ab1fb4" dependencies = [ - "bitflags 2.13.0", + "bitflags 1.3.2", "cexpr", "clang-sys", - "itertools", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", "proc-macro2", "quote", "regex", "rustc-hash", "shlex 1.3.0", - "syn 2.0.117", + "syn 1.0.109", + "which", ] [[package]] @@ -153,6 +145,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -171,6 +172,17 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "blowfish" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32fa6a061124e37baba002e496d203e23ba3d7b73750be82dbfbc92913048a5b" +dependencies = [ + "byteorder", + "cipher", + "opaque-debug", +] + [[package]] name = "borsh" version = "1.6.1" @@ -189,7 +201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -197,9 +209,9 @@ dependencies = [ [[package]] name = "btoi" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad" +checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976" dependencies = [ "num-traits", ] @@ -288,8 +300,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex 2.0.1", ] @@ -338,6 +348,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801" +dependencies = [ + "generic-array", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -349,15 +368,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.4" @@ -443,47 +453,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -518,6 +487,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "crypto-mac" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -527,6 +506,41 @@ dependencies = [ "cmov", ] +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + [[package]] name = "der" version = "0.7.10" @@ -534,10 +548,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_utils" version = "0.15.1" @@ -549,6 +576,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" @@ -616,7 +652,7 @@ dependencies = [ "stacker", "tar", "toml", - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", "ureq", ] @@ -665,13 +701,26 @@ dependencies = [ name = "elephc-pdo" version = "0.1.0" dependencies = [ + "bytes", "chrono", + "futures-util", + "libloading", + "libpq", "libsqlite3-sys", "mysql", - "postgres", + "odbc-sys", + "oracle", + "rsfbclient", "rust_decimal", + "rustls", + "rustls-pemfile", "serde_json", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", "uuid", + "webpki-roots 0.26.11", + "whoami", ] [[package]] @@ -721,6 +770,70 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +dependencies = [ + "encoding-index-japanese", + "encoding-index-korean", + "encoding-index-simpchinese", + "encoding-index-singlebyte", + "encoding-index-tradchinese", +] + +[[package]] +name = "encoding-index-japanese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-korean" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-simpchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-singlebyte" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-tradchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding_index_tests" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" + [[package]] name = "equivalent" version = "1.0.2" @@ -768,6 +881,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -778,6 +897,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -831,6 +956,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -850,6 +986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-macro", "futures-sink", "futures-task", "pin-project-lite", @@ -934,8 +1071,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash", ] @@ -951,6 +1086,22 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + [[package]] name = "hmac" version = "0.13.0" @@ -960,6 +1111,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.2" @@ -1160,6 +1320,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1252,31 +1418,12 @@ dependencies = [ "derive_utils", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.100" @@ -1315,6 +1462,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1349,6 +1502,30 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libpq" +version = "5.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457febd48c79e1c69729a1a706144943c3ae0185cb7317efcb90a1a8f843994d" +dependencies = [ + "bitflags 2.13.0", + "libc", + "libpq-sys", + "log", + "thiserror 2.0.19", +] + +[[package]] +name = "libpq-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef060ac05c207c85da15f4eb629100c8782e0db4c06a3c91c86be9c18ae8a23" +dependencies = [ + "bindgen", + "pkg-config", + "vcpkg", +] + [[package]] name = "libredox" version = "0.1.17" @@ -1369,6 +1546,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1398,11 +1587,28 @@ checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru" -version = "0.12.5" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" + +[[package]] +name = "lru-cache" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" dependencies = [ - "hashbrown 0.15.5", + "linked-hash-map", +] + +[[package]] +name = "md-5" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5a279bb9607f9f53c22d496eade00d138d1bdcccd07d74650387cf94942a15" +dependencies = [ + "block-buffer 0.9.0", + "digest 0.9.0", + "opaque-debug", ] [[package]] @@ -1494,13 +1700,12 @@ checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" [[package]] name = "mysql" -version = "25.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6ad644efb545e459029b1ffa7c969d830975bd76906820913247620df10050b" +version = "28.0.0" dependencies = [ "bufstream", "bytes", - "crossbeam", + "crossbeam-queue", + "crossbeam-utils", "flate2", "io-enum", "libc", @@ -1509,44 +1714,39 @@ dependencies = [ "named_pipe", "pem", "percent-encoding", - "serde", - "serde_json", - "socket2 0.5.10", + "rustls", + "rustls-pemfile", + "socket2 0.6.4", "twox-hash", "url", + "webpki", + "webpki-roots 1.0.7", ] [[package]] name = "mysql_common" -version = "0.32.4" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478b0ff3f7d67b79da2b96f56f334431aef65e15ba4b29dd74a4236e29582bdc" +checksum = "0f27695f286b461da077b8c2f72f47feaa04ce3c3f9c0976257410e90e21208a" dependencies = [ - "base64 0.21.7", - "bindgen", + "base64", "bitflags 2.13.0", "btoi", "byteorder", "bytes", - "cc", - "cmake", "crc32fast", "flate2", - "lazy_static", + "getrandom 0.3.4", "num-bigint", "num-traits", - "rand 0.8.6", "regex", "saturating", "serde", "serde_json", "sha1", "sha2 0.10.9", - "smallvec", - "subprocess", - "thiserror", + "thiserror 2.0.19", "uuid", - "zstd", ] [[package]] @@ -1624,6 +1824,27 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -1654,14 +1875,61 @@ version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ - "memchr", + "memchr", +] + +[[package]] +name = "odbc-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245cb4fe8236df4fd352ba96075d754233c6509d654d9f1c1482158b7d6c083d" + +[[package]] +name = "odpic-sys" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "oracle" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0" +dependencies = [ + "cc", + "odpic-sys", + "once_cell", + "oracle_procmacro", + "paste", + "rustversion", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "oracle_procmacro" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] [[package]] name = "parking_lot" @@ -1686,13 +1954,25 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64 0.22.1", + "base64", "serde_core", ] @@ -1795,31 +2075,17 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" -[[package]] -name = "postgres" -version = "0.19.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1" -dependencies = [ - "bytes", - "fallible-iterator", - "futures-util", - "log", - "tokio", - "tokio-postgres", -] - [[package]] name = "postgres-protocol" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" dependencies = [ - "base64 0.22.1", + "base64", "byteorder", "bytes", "fallible-iterator", - "hmac", + "hmac 0.13.0", "md-5 0.11.0", "memchr", "rand 0.10.1", @@ -1870,13 +2136,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -1918,6 +2194,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pwhash" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419a3ad8fa9f9d445e69d9b185a24878ae6e6f55c96e4512f4a0e28cd3bc5c56" +dependencies = [ + "blowfish", + "byteorder", + "hmac 0.10.1", + "md-5 0.9.1", + "rand 0.8.6", + "sha-1 0.9.8", + "sha2 0.9.9", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -2125,6 +2416,64 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rsfbclient" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a80f4335b49c476b493dbab22786fb5a25e1ebf2436e2c393b4e82cf2b9e0bb5" +dependencies = [ + "chrono", + "lru-cache", + "percent-encoding", + "rsfbclient-core", + "rsfbclient-derive", + "rsfbclient-rust", + "url", +] + +[[package]] +name = "rsfbclient-core" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79df189861df4fa2be12f1d1baf902f86d65bbda63c8a7e7e259c47fe2845678" +dependencies = [ + "chrono", + "encoding", + "num_enum", + "regex", + "thiserror 1.0.69", +] + +[[package]] +name = "rsfbclient-derive" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73073992203fbe5925fcab44de57741ac884e6ce0114d173f4d71a585445fb2b" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rsfbclient-rust" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a718b805f258db30b621b64d9647f12fcc3543a37db06defe91e82f9b90db7" +dependencies = [ + "bytes", + "digest 0.10.7", + "generic-array", + "hex", + "lazy_static", + "num-bigint", + "num_enum", + "pwhash", + "rand 0.8.6", + "rsfbclient-core", + "sha-1 0.10.1", + "sha2 0.10.9", +] + [[package]] name = "rust_decimal" version = "1.42.0" @@ -2145,9 +2494,22 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] [[package]] name = "rustix" @@ -2158,7 +2520,7 @@ dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2288,6 +2650,30 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha-1" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2299,6 +2685,19 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2438,12 +2837,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strict-num" version = "0.1.1" @@ -2462,14 +2855,10 @@ dependencies = [ ] [[package]] -name = "subprocess" -version = "0.2.15" +name = "strsim" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c56e8662b206b9892d7a5a3f2ecdbcb455d3d6b259111373b7e08b8055158a8" -dependencies = [ - "libc", - "winapi", -] +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "subtle" @@ -2499,6 +2888,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -2533,7 +2933,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", ] [[package]] @@ -2547,6 +2956,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -2598,6 +3018,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio" version = "1.52.3" @@ -2638,6 +3079,30 @@ dependencies = [ "whoami", ] +[[package]] +name = "tokio-postgres-rustls" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb" +dependencies = [ + "rustls", + "sha2 0.11.0", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2666,6 +3131,12 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -2684,6 +3155,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.25.12+spec-1.1.0" @@ -2714,14 +3196,9 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "twox-hash" -version = "1.6.3" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" -dependencies = [ - "cfg-if", - "rand 0.8.6", - "static_assertions", -] +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" [[package]] name = "typenum" @@ -2786,7 +3263,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64 0.22.1", + "base64", "log", "percent-encoding", "rustls", @@ -2802,7 +3279,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64 0.22.1", + "base64", "http", "httparse", "log", @@ -2996,6 +3473,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -3020,6 +3507,18 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "whirlpool" version = "0.10.4" @@ -3214,6 +3713,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.15" @@ -3338,6 +3846,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "tls_codec", +] + [[package]] name = "xattr" version = "1.6.1" @@ -3345,7 +3865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -3417,6 +3937,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -3457,34 +3991,6 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "zune-core" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 03d9c74375..19e3d0f9db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,34 @@ flate2 = "1" rustls = { version = "0.23", default-features = false, features = ["std", "ring", "tls12"] } rustls-pemfile = "2" +[features] +# Keeps the codegen test package and its PDO dev-dependency on the same libpq +# backend profile. Without this relay, a later `cargo test` can rebuild and +# replace the feature-built staticlib with the pure-Rust default archive. +pdo-libpq-gss = ["elephc-pdo/libpq-gss"] +# Keeps codegen fixtures and the PDO archive on the FreeTDS-backed PDO_DBLIB profile. +pdo-dblib = ["elephc-pdo/dblib"] +# Keeps codegen fixtures and the PDO archive on the pure-Rust PDO_FIREBIRD profile. +pdo-firebird = ["elephc-pdo/firebird"] +# Keeps codegen fixtures and the PDO archive on the system ODBC-manager profile. +pdo-odbc = ["elephc-pdo/odbc"] +# Keeps codegen fixtures and the PDO archive on the Informix CLI/ODBC profile. +pdo-informix = ["elephc-pdo/informix"] +# Keeps codegen fixtures and the PDO archive on the IBM Db2 CLI profile. +pdo-ibm = ["elephc-pdo/ibm"] +# Keeps codegen fixtures and the PDO archive on Microsoft PDO_SQLSRV 5.13. +pdo-sqlsrv = ["elephc-pdo/sqlsrv"] +# Keeps codegen fixtures and the PDO archive on the Oracle Instant Client profile. +pdo-oci = ["elephc-pdo/oci"] +# Keeps codegen fixtures and the PDO archive on the official CUBRID CCI profile. +pdo-cubrid = ["elephc-pdo/cubrid"] + +# PDO needs two mysql client controls that upstream 28.0.0 does not expose: +# caller-supplied authentication RSA keys and rustls cipher-suite selection. +# Keep the small audited patch in-tree until those options land upstream. +[patch.crates-io] +mysql = { path = "vendor/mysql-28.0.0" } + # Local test-speed tuning. The dev profile (which `test` inherits) keeps full # `debug = 2` info by default, which inflates both rustc codegen and the link of # the six test binaries. `line-tables-only` keeps file:line in panic backtraces diff --git a/README.md b/README.md index 539b64531d..48cef8631b 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ I made the project as modular as possible. Every function has its own codegen fi ## What you can expect -You can write PHP using the constructs documented in the [docs](docs/). Classes with single inheritance, interfaces, `instanceof`, nullsafe access (`?->`), abstract classes, final classes, methods and typed/static properties, PHP-style static property redeclarations, constructor property promotion, traits, constructors, instance/static methods, case-insensitive PHP symbol lookup for functions/classes/methods, `self::` / `parent::` / `static::` with late static binding, `readonly` properties and classes, enums, PHP 8 attributes on declarations, named arguments, first-class callables, typed function and method parameters and returns, `try` / `catch` / `finally` / `throw`, visibility modifiers, union and nullable types, copy-on-write arrays, associative arrays with PHP insertion order and integer/numeric-string key normalization, array union with `+`, closures, generator functions and generator closures with `yield` / `yield from`, namespaces, includes, compile-time Composer/SPL autoloading, class/introspection helpers, `PDO` database access (`PDO` / `PDOStatement` / `PDOException`) with SQLite, PostgreSQL, and MySQL/MariaDB drivers, image creation and manipulation (GD raster I/O, drawing, transforms/filters, Exif/IPTC metadata, and the `Imagick`/`Gmagick`/Cairo object APIs) on a pure-Rust codec/raster bridge, and PHP 8.1-style `Fiber` coroutines on macOS ARM64, Linux ARM64, and Linux x86_64. +You can write PHP using the constructs documented in the [docs](docs/). Classes with single inheritance, interfaces, `instanceof`, nullsafe access (`?->`), abstract classes, final classes, methods and typed/static properties, PHP-style static property redeclarations, constructor property promotion, traits, constructors, instance/static methods, case-insensitive PHP symbol lookup for functions/classes/methods, `self::` / `parent::` / `static::` with late static binding, `readonly` properties and classes, enums, PHP 8 attributes on declarations, named arguments, first-class callables, typed function and method parameters and returns, `try` / `catch` / `finally` / `throw`, visibility modifiers, union and nullable types, copy-on-write arrays, associative arrays with PHP insertion order and integer/numeric-string key normalization, array union with `+`, closures, generator functions and generator closures with `yield` / `yield from`, namespaces, includes, compile-time Composer/SPL autoloading, class/introspection helpers, `PDO` database access (`PDO` / `PDOStatement` / `PDOException`) with SQLite, PostgreSQL, MySQL/MariaDB, and optional DBLIB, Firebird, ODBC, Informix, IBM, SQLSRV, and Oracle drivers, image creation and manipulation (GD raster I/O, drawing, transforms/filters, Exif/IPTC metadata, and the `Imagick`/`Gmagick`/Cairo object APIs) on a pure-Rust codec/raster bridge, and PHP 8.1-style `Fiber` coroutines on macOS ARM64, Linux ARM64, and Linux x86_64. Experimental [`eval()` support](docs/php/eval.md) AOT-lowers eligible literal fragments and falls back to the optional, statically linked Magician interpreter for dynamic fragments. Runnable examples live in [`examples/eval/`](examples/eval/), [`examples/eval-globals/`](examples/eval-globals/), and the opt-in regex example [`examples/eval_regex/`](examples/eval_regex/). @@ -391,7 +391,7 @@ The full list of supported constructs, operators, and control structures is in t - **Types**: union types (`int|string`), nullable (`?int`), `never` return type, `iterable` pseudo-type, inferred `resource|false` values for `fopen()` and `resource` values for standard streams, type casting, typed properties, typed function, method, closure, and arrow parameters and returns - **Modules**: namespaces, use imports, include/require/include_once/require_once, compile-time Composer PSR-4/PSR-0/classmap/files autoloading, `spl_autoload_register()` rule extraction, PHP magic constants - **FFI**: extern functions, extern blocks, extern globals, extern classes, pointer builtins -- **Database (PDO)**: `PDO`, `PDOStatement`, `PDOException` with SQLite, PostgreSQL, and MySQL/MariaDB drivers, positional `?` and named `:name` binds, fetch modes, transactions, and `foreach` over result sets +- **Database (PDO)**: `PDO`, `PDOStatement`, `PDOException` with SQLite, PostgreSQL, MySQL/MariaDB, optional FreeTDS PDO_DBLIB, pure-Rust PDO_FIREBIRD, system-driver-manager PDO_ODBC, Client SDK PDO_INFORMIX/PDO_IBM, Microsoft ODBC PDO_SQLSRV, Oracle Instant Client PDO_OCI, and official CCI PDO_CUBRID drivers, positional `?` and named `:name` binds, fetch modes, transactions, and `foreach` over result sets - **Date/time**: `DateTime`, `DateTimeImmutable`, `DateTimeInterface`, `DateTimeZone`, `DateInterval`, `DatePeriod`, the PHP 8.3 date exception hierarchy, DST-aware formatting via a bundled IANA timezone database, and `ext/calendar` Julian-Day functions - **Web server (`--web`)**: standalone prefork HTTP server binaries with `$_SERVER`/`$_GET`/`$_POST` and `php://input` request input, `header()`/`http_response_code()` response control, and PHP-compatible sessions — `$_SESSION`, the complete `session_*()` API, file persistence, custom save handlers, strict mode, cookies and cache limiters, and trans-SID rewriting - **Extensions**: `ifdef`, `packed class`, `buffer`, `buffer_new()`, `buffer_len()`, `buffer_free()` @@ -541,7 +541,7 @@ src/ ├── names.rs # Qualified/FQN name model + symbol mangling helpers ├── name_resolver/ # Namespace/use resolution to canonical names ├── pdo_prelude.rs # PDO standard-library prelude (PHP source) injection entry point -├── pdo_prelude/ # PDO driver detection from the DSN prefix (sqlite/pgsql/mysql) +├── pdo_prelude/ # PDO driver detection, version gates, and driver subclasses ├── tz_prelude.rs # Timezone-introspection prelude injection entry point ├── tz_prelude/ # Timezone-introspection prelude usage detection ├── list_id_prelude.rs # DateTimeZone identifier-list prelude injection entry point diff --git a/crates/elephc-magician/src/eval_php_profile.rs b/crates/elephc-magician/src/eval_php_profile.rs index 58c851d7d7..76ffb15b5b 100644 --- a/crates/elephc-magician/src/eval_php_profile.rs +++ b/crates/elephc-magician/src/eval_php_profile.rs @@ -33,15 +33,18 @@ const DEFAULT_EVAL_PHP_VERSION_ID: u32 = 80500; /// The supported profiles, paired with the `PHP_VERSION` string each one reports. /// -/// KEEP IN SYNC with `crate::web_prelude::PhpVersion::ALL` and `version_string()` +/// KEEP IN SYNC with `crate::php_version::PhpVersion::ALL` and `version_string()` /// in the compiler. The patch component is `0` for every entry by the same rule /// the compiler applies — elephc targets a language profile, not an upstream /// patch release. const EVAL_PHP_PROFILES: &[(u32, &str)] = &[ + (80000, "8.0.0"), + (80100, "8.1.0"), (80200, "8.2.0"), (80300, "8.3.0"), (80400, "8.4.0"), (80500, "8.5.0"), + (80600, "8.6.0"), ]; thread_local! { @@ -136,7 +139,7 @@ mod tests { #[test] fn an_unsupported_id_is_ignored() { let _guard = scoped_profile(80200); - set_eval_php_version_id(80600); + set_eval_php_version_id(90000); assert_eq!(eval_php_version_id(), 80200); assert_eq!(eval_php_version_string(), "8.2.0"); } diff --git a/crates/elephc-pdo/Cargo.toml b/crates/elephc-pdo/Cargo.toml index 466a9e96d8..a675f8d67e 100644 --- a/crates/elephc-pdo/Cargo.toml +++ b/crates/elephc-pdo/Cargo.toml @@ -3,7 +3,7 @@ name = "elephc-pdo" version = "0.1.0" edition = "2021" license = "MIT" -description = "Multi-driver database bridge staticlib (SQLite + PostgreSQL) for the elephc PHP-to-native compiler's PDO support" +description = "Multi-driver database bridge staticlib for the elephc PHP-to-native compiler's PDO support" # Built as both a C-callable staticlib (linked into compiled PHP programs that # use PDO) and an rlib (so the bridge can be unit-tested from Rust via @@ -22,11 +22,16 @@ libsqlite3-sys = { version = "0.30", features = ["bundled"] } # TCP/Unix sockets when a `pgsql:` DSN is opened. The `with-*` features add the # FromSql decoders for date/time, json, and uuid columns so the driver can read # those types back as their PHP-string representation. -postgres = { version = "0.19", features = [ +tokio-postgres = { version = "0.7", features = [ "with-chrono-0_4", "with-serde_json-1", "with-uuid-1", ] } +tokio = { version = "1", features = ["rt-multi-thread", "sync"] } +futures-util = { version = "0.3", features = ["sink"] } +bytes = "1" +whoami = "2" +libpq = { version = "5.0.1", features = ["v12"], optional = true } # Type crates named directly in the column decoder (the postgres features above # only wire up the FromSql impls). rust_decimal's `db-postgres` feature adds the # numeric/decimal FromSql impl (postgres has no built-in one). @@ -34,10 +39,77 @@ chrono = "0.4" serde_json = "1" uuid = "1" rust_decimal = { version = "1", features = ["db-postgres"] } +# Optional pure-Rust Firebird wire client. php-src uses libfbclient, but the +# protocol-backed profile keeps elephc's supported target matrix standalone +# while preserving PDO_FIREBIRD's SQL, transaction, and scalar semantics. +rsfbclient = { version = "0.27", default-features = false, features = ["pure_rust"], optional = true } +# Thin ODBC 3.8 C ABI bindings. The selected profile links the platform's +# unixODBC driver manager, matching php-src's delegation model. +odbc-sys = { version = "0.31", default-features = false, features = ["odbc_version_3_80"], optional = true } +# Oracle's supported ODPI-C wrapper. ODPI-C loads the system Oracle Instant +# Client at runtime, preserving PDO_OCI's external-client dependency without +# requiring proprietary headers or libraries while compiling elephc itself. +oracle = { version = "0.6.3", optional = true } +# CUBRID's official PDO extension delegates to CCI. Load the CCI shared library +# at runtime so the optional profile remains buildable on CI and on developer +# machines without a locally installed CUBRID client. +libloading = { version = "0.8", optional = true } # The synchronous, pure-Rust MySQL/MariaDB client. `default-features = false` with # `minimal-rust` drops the optional TLS backends and selects flate2's pure-Rust # (miniz_oxide) deflate backend instead of the C `zlib` one, so the staticlib has # no `libz` dependency and compiled PHP binaries stay fully standalone (no system # DB client, no `-lz`) while keeping the binary wire protocol and prepared -# statements this driver needs. -mysql = { version = "25", default-features = false, features = ["minimal-rust"] } +# statements this driver needs. mysql 28 exposes a ring-backed rustls feature, so +# TLS no longer needs aws-lc-rs or a C/asm crypto toolchain. +mysql = { version = "28", default-features = false, features = ["minimal-rust"] } +# --- TLS (gated by the `tls` / `mysql-tls` features below) --- +# PostgreSQL TLS uses rustls with the RING crypto provider ONLY (no aws-lc-rs), +# mirroring crates/elephc-tls so the musl/Docker images and standalone binaries +# stay free of the aws-lc C/asm toolchain prerequisites. tokio-postgres-rustls +# exposes a `ring` feature, so `default-features = false` keeps aws-lc-rs out. +tokio-postgres-rustls = { version = "0.14", default-features = false, features = [ + "ring", +], optional = true } +# The pg connector's rustls ClientConfig is built explicitly with the ring +# provider (ClientConfig::builder_with_provider), so the pg path never consults a +# process-global default provider. `std`/`tls12` mirror elephc-tls. +rustls = { version = "0.23", default-features = false, features = [ + "ring", + "std", + "tls12", +], optional = true } +# Default trust roots for pg verification when no custom `sslrootcert` is given. +webpki-roots = { version = "0.26", optional = true } +# Parses a custom `sslrootcert`/client-cert PEM into the pg rustls config. +rustls-pemfile = { version = "2", optional = true } + +[features] +# TLS is ON by default, but the default `tls` feature covers PostgreSQL ONLY and +# is aws-lc-rs-free (ring): a `pgsql:` DSN with `sslmode=require`/`verify-full` +# works out of the box against managed Postgres (Supabase/Neon/RDS/Timescale). +# mysql 28's `rustls-tls-ring` keeps the MySQL path on the same provider as +# PostgreSQL and elephc-tls, so both database TLS backends are enabled by default +# without introducing aws-lc-rs. A minimal `--no-default-features` build keeps the +# historical NoTls behavior for both. +default = ["tls", "mysql-tls"] +tls = ["dep:tokio-postgres-rustls", "dep:rustls", "dep:webpki-roots", "dep:rustls-pemfile"] +mysql-tls = ["tls", "mysql/rustls-tls-ring"] +libpq-gss = ["dep:libpq"] +# Matches php-src's PDO_DBLIB backend by linking the system FreeTDS DB-Library. +# The default bridge remains system-client-free; final binaries using this +# profile must also link `libsybdb`. +dblib = [] +firebird = ["dep:rsfbclient"] +# Matches php-src's PDO_ODBC backend through the system ODBC driver manager. +odbc = ["dep:odbc-sys"] +# Matches the current PECL PDO_INFORMIX driver through the Informix ODBC/CLI +# driver. A live connection requires IBM/HCL Client SDK on the host. +informix = ["dep:odbc-sys"] +# Matches PECL PDO_IBM through the IBM CLI/ODBC ABI exposed by the driver manager. +ibm = ["dep:odbc-sys"] +# Matches Microsoft PDO_SQLSRV 5.13 through Microsoft ODBC Driver 18/17. +sqlsrv = ["dep:odbc-sys"] +# Matches the current PECL PDO_OCI driver through Oracle Instant Client/ODPI-C. +oci = ["dep:oracle"] +# Matches the official PDO_CUBRID extension through CUBRID CCI (`libcascci`). +cubrid = ["dep:libloading"] diff --git a/crates/elephc-pdo/build.rs b/crates/elephc-pdo/build.rs new file mode 100644 index 0000000000..fb403e7c5f --- /dev/null +++ b/crates/elephc-pdo/build.rs @@ -0,0 +1,21 @@ +//! Purpose: +//! Build-time native-library discovery for optional PDO system-client profiles. +//! +//! Called from: +//! - Cargo while compiling `elephc-pdo`. +//! +//! Key details: +//! - Homebrew's keg-only FreeTDS directory is added only for the `dblib` profile. + +use std::path::Path; + +/// Emits native search paths required by enabled system-client features. +fn main() { + if std::env::var_os("CARGO_FEATURE_DBLIB").is_some() { + for path in ["/opt/homebrew/opt/freetds/lib", "/usr/local/opt/freetds/lib"] { + if Path::new(path).is_dir() { + println!("cargo:rustc-link-search=native={path}"); + } + } + } +} diff --git a/crates/elephc-pdo/src/cubrid.rs b/crates/elephc-pdo/src/cubrid.rs new file mode 100644 index 0000000000..7b89c01f9c --- /dev/null +++ b/crates/elephc-pdo/src/cubrid.rs @@ -0,0 +1,1598 @@ +//! Purpose: +//! CUBRID CCI backend matching the official external PDO_CUBRID extension. +//! +//! Called from: +//! - The PDO bridge root when built with the optional `cubrid` feature. +//! +//! Key details: +//! - Loads the same `libcascci` client used upstream at runtime, keeping builds SDK-independent. +//! - Owns every CCI connection/request handle and copies transient CCI result metadata immediately. +//! - Materializes result rows so PDO's forward and scroll fetch orientations share one safe path. + +use std::collections::HashMap; +use std::ffi::{c_char, c_int, c_long, c_uchar, c_ulong, c_void, CStr, CString}; +use std::ptr; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::OnceLock; + +use libloading::Library; + +const CCI_ER_DBMS: i32 = -20_001; +const CCI_ER_NO_MORE_DATA: i32 = -20_005; +const CAS_ER_NOT_IMPLEMENTED: i32 = -10_100; +const CAS_ER_NO_MORE_RESULT_SET: i32 = -10_022; +const CCI_EXEC_QUERY_ALL: c_char = 0x02; +const CCI_TRAN_COMMIT: c_char = 1; +const CCI_TRAN_ROLLBACK: c_char = 2; +const CCI_CURSOR_CURRENT: i32 = 1; +const CCI_A_TYPE_STR: i32 = 1; +const CCI_A_TYPE_INT: i32 = 2; +const CCI_A_TYPE_DOUBLE: i32 = 4; +const CCI_A_TYPE_BIT: i32 = 5; +const CCI_A_TYPE_SET: i32 = 7; +const CCI_A_TYPE_BIGINT: i32 = 8; +const CCI_A_TYPE_BLOB: i32 = 9; +const CCI_A_TYPE_CLOB: i32 = 10; +const CCI_U_TYPE_NULL: i32 = 0; +const CCI_U_TYPE_STRING: i32 = 2; +const CCI_U_TYPE_INT: i32 = 8; +const CCI_U_TYPE_DOUBLE: i32 = 12; +const CCI_U_TYPE_BIGINT: i32 = 21; +const CCI_U_TYPE_BLOB: i32 = 23; +const CCI_U_TYPE_CLOB: i32 = 24; +const CCI_U_TYPE_BIT: i32 = 5; +const CCI_U_TYPE_VARBIT: i32 = 6; +const CCI_U_TYPE_SET: i32 = 16; +const CUBRID_STMT_INSERT: i32 = 20; +const CUBRID_STMT_SELECT: i32 = 21; +const CUBRID_STMT_UPDATE: i32 = 22; +const CUBRID_STMT_DELETE: i32 = 23; + +static OPEN_ERROR_CODE: AtomicI64 = AtomicI64::new(0); + +#[repr(C)] +#[derive(Clone)] +struct NativeError { + err_code: c_int, + err_msg: [c_char; 1024], +} + +impl Default for NativeError { + /// Creates an empty CCI error buffer for one native call. + fn default() -> Self { + Self { + err_code: 0, + err_msg: [0; 1024], + } + } +} + +#[repr(C)] +struct NativeColumn { + ext_type: c_uchar, + is_non_null: c_char, + scale: i16, + precision: c_int, + col_name: *mut c_char, + real_attr: *mut c_char, + class_name: *mut c_char, + default_value: *mut c_char, + is_auto_increment: c_char, + is_unique_key: c_char, + is_primary_key: c_char, + is_foreign_key: c_char, + is_reverse_index: c_char, + is_reverse_unique: c_char, + is_shared: c_char, + charset: c_int, +} + +#[repr(C)] +struct NativeBit { + size: c_int, + buffer: *mut c_char, +} + +type InitFn = unsafe extern "C" fn(); +type EndFn = unsafe extern "C" fn(); +type VersionFn = unsafe extern "C" fn(*mut c_int, *mut c_int, *mut c_int) -> c_int; +type ConnectFn = unsafe extern "C" fn(*mut c_char, *mut c_char, *mut c_char, *mut NativeError) -> c_int; +type DisconnectFn = unsafe extern "C" fn(c_int, *mut NativeError) -> c_int; +type EndTranFn = unsafe extern "C" fn(c_int, c_char, *mut NativeError) -> c_int; +type PrepareFn = unsafe extern "C" fn(c_int, *const c_char, c_char, *mut NativeError) -> c_int; +type BindFn = unsafe extern "C" fn(c_int, c_int, c_int, *mut c_void, c_int, c_char) -> c_int; +type ExecuteFn = unsafe extern "C" fn(c_int, c_char, c_int, *mut NativeError) -> c_int; +type ResultInfoFn = unsafe extern "C" fn(c_int, *mut c_int, *mut c_int) -> *mut NativeColumn; +type CloseRequestFn = unsafe extern "C" fn(c_int) -> c_int; +type CursorFn = unsafe extern "C" fn(c_int, c_int, c_int, *mut NativeError) -> c_int; +type FetchFn = unsafe extern "C" fn(c_int, *mut NativeError) -> c_int; +type FetchBufferClearFn = unsafe extern "C" fn(c_int) -> c_int; +type GetDataFn = unsafe extern "C" fn(c_int, c_int, c_int, *mut c_void, *mut c_int) -> c_int; +type NextResultFn = unsafe extern "C" fn(c_int, *mut NativeError) -> c_int; +type SchemaFn = unsafe extern "C" fn(c_int, c_int, *mut c_char, *mut c_char, c_char, *mut NativeError) -> c_int; +type GetDbVersionFn = unsafe extern "C" fn(c_int, *mut c_char, c_int) -> c_int; +type GetAutocommitFn = unsafe extern "C" fn(c_int) -> c_int; +type SetAutocommitFn = unsafe extern "C" fn(c_int, c_int) -> c_int; +type GetDbParameterFn = unsafe extern "C" fn(c_int, c_int, *mut c_void, *mut NativeError) -> c_int; +type SetIsolationLevelFn = unsafe extern "C" fn(c_int, c_int, *mut NativeError) -> c_int; +type SetLockTimeoutFn = unsafe extern "C" fn(c_int, c_int, *mut NativeError) -> c_int; +type SetQueryTimeoutFn = unsafe extern "C" fn(c_int, c_int) -> c_int; +type EscapeStringFn = unsafe extern "C" fn( + c_int, + *mut c_char, + *const c_char, + c_ulong, + *mut NativeError, +) -> c_long; +type LastInsertIdFn = unsafe extern "C" fn(c_int, *mut c_void, *mut NativeError) -> c_int; +type BlobNewFn = unsafe extern "C" fn(c_int, *mut *mut c_void, *mut NativeError) -> c_int; +type BlobSizeFn = unsafe extern "C" fn(*mut c_void) -> i64; +type BlobWriteFn = unsafe extern "C" fn(c_int, *mut c_void, i64, c_int, *const c_char, *mut NativeError) -> c_int; +type BlobReadFn = unsafe extern "C" fn(c_int, *mut c_void, i64, c_int, *mut c_char, *mut NativeError) -> c_int; +type BlobFreeFn = unsafe extern "C" fn(*mut c_void) -> c_int; +type SetMakeFn = unsafe extern "C" fn(*mut *mut c_void, c_int, c_int, *mut c_void, *mut c_int) -> c_int; +type SetFreeFn = unsafe extern "C" fn(*mut c_void); + +struct CciApi { + _library: Library, + init: InitFn, + end: EndFn, + version: VersionFn, + connect: ConnectFn, + disconnect: DisconnectFn, + end_tran: EndTranFn, + prepare: PrepareFn, + bind: BindFn, + execute: ExecuteFn, + result_info: ResultInfoFn, + close_request: CloseRequestFn, + cursor: CursorFn, + fetch: FetchFn, + fetch_buffer_clear: FetchBufferClearFn, + get_data: GetDataFn, + next_result: NextResultFn, + schema: SchemaFn, + get_db_version: GetDbVersionFn, + get_autocommit: GetAutocommitFn, + set_autocommit: SetAutocommitFn, + get_db_parameter: GetDbParameterFn, + set_isolation_level: SetIsolationLevelFn, + set_lock_timeout: SetLockTimeoutFn, + set_query_timeout: SetQueryTimeoutFn, + escape_string: EscapeStringFn, + last_insert_id: LastInsertIdFn, + blob_new: BlobNewFn, + blob_size: BlobSizeFn, + blob_write: BlobWriteFn, + blob_read: BlobReadFn, + blob_free: BlobFreeFn, + clob_new: BlobNewFn, + clob_size: BlobSizeFn, + clob_write: BlobWriteFn, + clob_read: BlobReadFn, + clob_free: BlobFreeFn, + set_make: SetMakeFn, + set_free: SetFreeFn, +} + +/// Copies one function pointer out of a loaded CCI library. +unsafe fn symbol(library: &Library, name: &[u8]) -> Result { + library + .get::(name) + .map(|symbol| *symbol) + .map_err(|error| format!("missing {}: {error}", String::from_utf8_lossy(name))) +} + +impl CciApi { + /// Loads CUBRID CCI from an explicit override or the platform's conventional names. + fn load() -> Result { + let mut candidates = Vec::new(); + if let Some(path) = std::env::var_os("CUBRID_CCI_LIBRARY") { + candidates.push(path); + } + #[cfg(target_os = "macos")] + candidates.extend(["libcascci.dylib".into(), "libcascci.so".into()]); + #[cfg(target_os = "linux")] + candidates.extend(["libcascci.so".into(), "libcascci.so.11".into()]); + #[cfg(target_os = "windows")] + candidates.push("cascci.dll".into()); + let mut failures = Vec::new(); + for candidate in candidates { + let library = match unsafe { Library::new(&candidate) } { + Ok(library) => library, + Err(error) => { + failures.push(format!("{}: {error}", candidate.to_string_lossy())); + continue; + } + }; + let loaded = unsafe { + Ok::<_, String>(Self { + init: symbol(&library, b"cci_init\0")?, + end: symbol(&library, b"cci_end\0")?, + version: symbol(&library, b"cci_get_version\0")?, + connect: symbol(&library, b"cci_connect_with_url_ex\0")?, + disconnect: symbol(&library, b"cci_disconnect\0")?, + end_tran: symbol(&library, b"cci_end_tran\0")?, + prepare: symbol(&library, b"cci_prepare\0")?, + bind: symbol(&library, b"cci_bind_param\0")?, + execute: symbol(&library, b"cci_execute\0")?, + result_info: symbol(&library, b"cci_get_result_info\0")?, + close_request: symbol(&library, b"cci_close_req_handle\0")?, + cursor: symbol(&library, b"cci_cursor\0")?, + fetch: symbol(&library, b"cci_fetch\0")?, + fetch_buffer_clear: symbol(&library, b"cci_fetch_buffer_clear\0")?, + get_data: symbol(&library, b"cci_get_data\0")?, + next_result: symbol(&library, b"cci_next_result\0")?, + schema: symbol(&library, b"cci_schema_info\0")?, + get_db_version: symbol(&library, b"cci_get_db_version\0")?, + get_autocommit: symbol(&library, b"cci_get_autocommit\0")?, + set_autocommit: symbol(&library, b"cci_set_autocommit\0")?, + get_db_parameter: symbol(&library, b"cci_get_db_parameter\0")?, + set_isolation_level: symbol(&library, b"cci_set_isolation_level\0")?, + set_lock_timeout: symbol(&library, b"cci_set_lock_timeout\0")?, + set_query_timeout: symbol(&library, b"cci_set_query_timeout\0")?, + escape_string: symbol(&library, b"cci_escape_string\0")?, + last_insert_id: symbol(&library, b"cci_get_last_insert_id\0")?, + blob_new: symbol(&library, b"cci_blob_new\0")?, + blob_size: symbol(&library, b"cci_blob_size\0")?, + blob_write: symbol(&library, b"cci_blob_write\0")?, + blob_read: symbol(&library, b"cci_blob_read\0")?, + blob_free: symbol(&library, b"cci_blob_free\0")?, + clob_new: symbol(&library, b"cci_clob_new\0")?, + clob_size: symbol(&library, b"cci_clob_size\0")?, + clob_write: symbol(&library, b"cci_clob_write\0")?, + clob_read: symbol(&library, b"cci_clob_read\0")?, + clob_free: symbol(&library, b"cci_clob_free\0")?, + set_make: symbol(&library, b"cci_set_make\0")?, + set_free: symbol(&library, b"cci_set_free\0")?, + _library: library, + }) + }; + match loaded { + Ok(api) => { + unsafe { (api.init)() }; + return Ok(api); + } + Err(error) => failures.push(error), + } + } + Err(format!( + "CUBRID CCI client library was not found ({})", + failures.join("; ") + )) + } +} + +impl Drop for CciApi { + /// Shuts CCI down before unloading its shared library at process termination. + fn drop(&mut self) { + unsafe { (self.end)() }; + } +} + +/// Returns the process-wide dynamically loaded CCI API. +fn api() -> Result<&'static CciApi, String> { + static API: OnceLock> = OnceLock::new(); + API.get_or_init(CciApi::load).as_ref().map_err(Clone::clone) +} + +/// Copies a nullable C string owned by CCI into Rust storage. +fn copy_c_string(pointer: *const c_char) -> String { + if pointer.is_null() { + String::new() + } else { + unsafe { CStr::from_ptr(pointer) }.to_string_lossy().into_owned() + } +} + +/// Converts arbitrary text to a C-compatible string without panicking on NUL bytes. +fn c_string(value: &str) -> Result { + CString::new(value).map_err(|_| "CUBRID value contains a NUL byte".to_string()) +} + +/// Percent-decodes the credential encoding used by the PDO prelude. +fn decode_component(value: &str) -> String { + let bytes = value.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let pair = std::str::from_utf8(&bytes[index + 1..index + 3]).ok(); + if let Some(decoded) = pair.and_then(|pair| u8::from_str_radix(pair, 16).ok()) { + output.push(decoded); + index += 3; + continue; + } + } + output.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&output).into_owned() +} + +struct Dsn { + url: String, + user: String, + password: String, +} + +/// Converts PDO_CUBRID's semicolon DSN into the official CCI connection URL. +fn parse_dsn(dsn: &str) -> Result { + let body = dsn + .strip_prefix("cubrid:") + .ok_or_else(|| "Invalid CUBRID data source name".to_string())?; + let mut values = HashMap::new(); + let mut extras = Vec::new(); + for part in body.split(';').filter(|part| !part.is_empty()) { + let (key, value) = part + .split_once('=') + .ok_or_else(|| "Invalid CUBRID connection string".to_string())?; + let key_lower = key.trim().to_ascii_lowercase(); + let value = if matches!(key_lower.as_str(), "user" | "password") { + decode_component(value.trim()) + } else { + value.trim().to_string() + }; + if matches!(key_lower.as_str(), "host" | "port" | "dbname" | "user" | "password") { + values.insert(key_lower, value); + } else { + extras.push((key.to_string(), value)); + } + } + let host = values.remove("host").unwrap_or_else(|| "localhost".to_string()); + let port = values.remove("port").unwrap_or_else(|| "55300".to_string()); + let dbname = values.remove("dbname").unwrap_or_else(|| "demodb".to_string()); + let user = values.remove("user").unwrap_or_else(|| "public".to_string()); + let password = values.remove("password").unwrap_or_default(); + let mut url = format!("cci:CUBRID:{host}:{port}:{dbname}:{user}:{password}:"); + for (index, (key, value)) in extras.into_iter().enumerate() { + url.push(if index == 0 { '?' } else { '&' }); + url.push_str(&key); + url.push('='); + url.push_str(&value); + } + Ok(Dsn { url, user, password }) +} + +#[derive(Clone)] +struct ErrorState { + sqlstate: String, + code: i64, + message: String, +} + +impl Default for ErrorState { + /// Creates PDO's successful no-error state. + fn default() -> Self { + Self { + sqlstate: "00000".to_string(), + code: 0, + message: String::new(), + } + } +} + +/// Maps one CCI result/error buffer to PDO_CUBRID's HY000 diagnostic shape. +fn native_error(result: i32, native: &NativeError) -> ErrorState { + let (code, message) = if result == CCI_ER_DBMS { + (native.err_code as i64, format!("DBMS, {}", copy_c_string(native.err_msg.as_ptr()))) + } else { + let message = copy_c_string(native.err_msg.as_ptr()); + let facility = if result > -10_200 { + "CAS" + } else if result > -20_100 { + "CCI" + } else if result > -31_000 { + "CLIENT" + } else { + "UNKNOWN" + }; + let message = if message.is_empty() { format!("{facility}, CCI error {result}") } else { format!("{facility}, {message}") }; + (result as i64, message) + }; + ErrorState { + sqlstate: "HY000".to_string(), + code, + message, + } +} + +/// Returns the SQLSTATE and native code captured by the latest failed CUBRID open. +pub fn open_diagnostic() -> (&'static str, i64) { + ("HY000", OPEN_ERROR_CODE.load(Ordering::Relaxed)) +} + +/// Records a constructor failure for PDOException and returns its display text. +fn record_open_error(error: &ErrorState) -> String { + OPEN_ERROR_CODE.store(error.code, Ordering::Relaxed); + error.message.clone() +} + +/// Decodes CCI's `TCCT TTTT` extended-type representation into its scalar domain. +fn collection_domain(ext_type: u8) -> u8 { + ((ext_type & 0x80) >> 2) | (ext_type & 0x1f) +} + +/// Resolves PDO_CUBRID's case-insensitive driver-option type names to CCI domains. +fn named_type(name: &str) -> Option { + match name.to_ascii_uppercase().as_str() { + "NULL" => Some(0), + "CHAR" => Some(1), + "STRING" => Some(2), + "NCHAR" => Some(3), + "VARNCHAR" => Some(4), + "BIT" => Some(5), + "VARBIT" => Some(6), + "NUMERIC" | "NUMBER" => Some(7), + "INT" => Some(8), + "SHORT" => Some(9), + "MONETARY" => Some(10), + "FLOAT" => Some(11), + "DOUBLE" => Some(12), + "DATE" => Some(13), + "TIME" => Some(14), + "TIMESTAMP" => Some(15), + "SET" => Some(16), + "MULTISET" => Some(17), + "SEQUENCE" => Some(18), + "OBJECT" => Some(19), + "RESULTSET" => Some(20), + "BIGINT" => Some(21), + "DATETIME" => Some(22), + "BLOB" => Some(23), + "CLOB" => Some(24), + "ENUM" => Some(25), + _ => None, + } +} + +/// Decodes the byte-length framing emitted by the PHP prelude for one CUBRID set. +fn decode_set(input: &[u8]) -> Option>> { + let mut cursor = input.iter().position(|byte| *byte == b':')?; + let count = std::str::from_utf8(&input[..cursor]).ok()?.parse::().ok()?; + cursor += 1; + let mut values = Vec::with_capacity(count); + for _ in 0..count { + let separator = input[cursor..].iter().position(|byte| *byte == b':')? + cursor; + let length = std::str::from_utf8(&input[cursor..separator]).ok()?.parse::().ok()?; + cursor = separator + 1; + let end = cursor.checked_add(length)?; + values.push(input.get(cursor..end)?.to_vec()); + cursor = end; + } + (cursor == input.len()).then_some(values) +} + +/// Returns the CUBRID native type spelling used by PDO_CUBRID metadata. +fn native_type_name(ext_type: u8, precision: i32, scale: i16) -> String { + let domain = collection_domain(ext_type); + let base = match domain { + 0 => "unknown".to_string(), + 1 => format!("char({precision})"), + 2 => format!("varchar({precision})"), + 3 => format!("nchar({precision})"), + 4 => format!("varnchar({precision})"), + 5 => "bit".to_string(), + 6 => format!("varbit({precision})"), + 7 => format!("numeric({precision},{scale})"), + 8 => "integer".to_string(), + 9 => "smallint".to_string(), + 10 => "monetary".to_string(), + 11 => "float".to_string(), + 12 => "double".to_string(), + 13 => "date".to_string(), + 14 => "time".to_string(), + 15 => "timestamp".to_string(), + 16 => "set".to_string(), + 17 => "multiset".to_string(), + 18 => "sequence".to_string(), + 19 => "object".to_string(), + 20 => "[unknown]".to_string(), + 21 => "bigint".to_string(), + 22 => "datetime".to_string(), + 23 => "blob".to_string(), + 24 => "clob".to_string(), + 25 => "enum".to_string(), + _ => "[unknown]".to_string(), + }; + match ext_type & 0x60 { + 0x20 => format!("set({base})"), + 0x40 => format!("multiset({base})"), + 0x60 => format!("sequence({base})"), + _ => base, + } +} + +/// Owns a live CUBRID CCI connection and its PDO-visible state. +pub struct CubridConn { + handle: i32, + auto_commit: bool, + configured_autocommit: bool, + query_timeout: i64, + pub in_transaction: bool, + pub changes: i64, + error: ErrorState, +} + +unsafe impl Send for CubridConn {} + +impl Drop for CubridConn { + /// Disconnects the native CCI session. + fn drop(&mut self) { + if let Ok(api) = api() { + let mut error = NativeError::default(); + unsafe { (api.disconnect)(self.handle, &mut error) }; + } + } +} + +impl CubridConn { + /// Opens a PDO_CUBRID DSN through the process CCI client. + pub fn open(dsn: &str) -> Result { + OPEN_ERROR_CODE.store(0, Ordering::Relaxed); + let api = api()?; + let dsn = parse_dsn(dsn).map_err(|message| { + OPEN_ERROR_CODE.store(-30_019, Ordering::Relaxed); + message + })?; + let url = c_string(&dsn.url)?; + let user = c_string(&dsn.user)?; + let password = c_string(&dsn.password)?; + let mut error = NativeError::default(); + let handle = unsafe { + (api.connect)( + url.as_ptr().cast_mut(), + user.as_ptr().cast_mut(), + password.as_ptr().cast_mut(), + &mut error, + ) + }; + if handle < 0 { + return Err(record_open_error(&native_error(handle, &error))); + } + let auto_commit = unsafe { (api.get_autocommit)(handle) }; + if auto_commit < 0 { + let mut disconnect_error = NativeError::default(); + unsafe { (api.disconnect)(handle, &mut disconnect_error) }; + OPEN_ERROR_CODE.store(auto_commit as i64, Ordering::Relaxed); + return Err(format!("CCI, CCI error {auto_commit}")); + } + let mut connection = Self { + handle, + auto_commit: auto_commit != 0, + configured_autocommit: auto_commit != 0, + query_timeout: -1, + in_transaction: false, + changes: 0, + error: ErrorState::default(), + }; + for parameter in [1, 2] { + let mut value = 0i32; + let mut error = NativeError::default(); + let result = unsafe { + (api.get_db_parameter)( + handle, + parameter, + (&mut value as *mut i32).cast(), + &mut error, + ) + }; + if result < 0 && result != CAS_ER_NOT_IMPLEMENTED { + connection.error = native_error(result, &error); + return Err(record_open_error(&connection.error)); + } + } + if !connection.commit_initial_transaction() { + return Err(record_open_error(&connection.error)); + } + Ok(connection) + } + + /// Commits CCI's initial connection transaction like the official extension factory. + fn commit_initial_transaction(&mut self) -> bool { + let mut error = NativeError::default(); + let result = unsafe { (api().expect("CCI loaded").end_tran)(self.handle, CCI_TRAN_COMMIT, &mut error) }; + if result < 0 { + self.error = native_error(result, &error); + false + } else { + true + } + } + + /// Executes one SQL statement and records the affected-row count. + pub fn exec(&mut self, sql: &str) -> Result { + let mut statement = CubridStmt::new(self, 0, sql)?; + statement.execute(self)?; + self.changes = statement.row_count; + Ok(self.changes) + } + + /// Probes the connection with the same `db_root` scalar query used upstream. + pub fn is_alive(&mut self) -> bool { + self.exec("select 1+1 from db_root").is_ok() + } + + /// Starts a transaction while retaining the configured post-transaction autocommit mode. + pub fn begin(&mut self) -> bool { + if self.in_transaction { + return false; + } + if self.configured_autocommit { + if !self.set_native_autocommit(false) { + return false; + } + self.auto_commit = false; + } else if !self.end_native_transaction(CCI_TRAN_COMMIT) { + return false; + } + self.in_transaction = true; + true + } + + /// Commits the current CCI transaction. + pub fn commit(&mut self) -> bool { + self.end_transaction(CCI_TRAN_COMMIT) + } + + /// Rolls back the current CCI transaction. + pub fn rollback(&mut self) -> bool { + self.end_transaction(CCI_TRAN_ROLLBACK) + } + + /// Ends a native transaction and restores configured autocommit. + fn end_transaction(&mut self, kind: c_char) -> bool { + if !self.end_native_transaction(kind) { + return false; + } + self.in_transaction = false; + if self.configured_autocommit { + if !self.set_native_autocommit(true) { + return false; + } + self.auto_commit = true; + } + true + } + + /// Ends one native CCI transaction without changing PDO's configured state. + fn end_native_transaction(&mut self, kind: c_char) -> bool { + let mut error = NativeError::default(); + let result = unsafe { (api().expect("CCI loaded").end_tran)(self.handle, kind, &mut error) }; + if result < 0 { + self.error = native_error(result, &error); + return false; + } + true + } + + /// Changes native CCI autocommit and records a diagnostic on failure. + fn set_native_autocommit(&mut self, enabled: bool) -> bool { + let result = unsafe { (api().expect("CCI loaded").set_autocommit)(self.handle, enabled as i32) }; + if result < 0 { + self.error = ErrorState { + sqlstate: "HY000".to_string(), + code: result as i64, + message: format!("CCI, CCI error {result}"), + }; + false + } else { + true + } + } + + /// Changes PDO_CUBRID's live autocommit setting outside a transaction. + pub fn set_autocommit(&mut self, enabled: bool) -> bool { + if self.auto_commit == enabled { + return true; + } + if !self.auto_commit && !self.end_native_transaction(CCI_TRAN_COMMIT) { + return false; + } + if !self.set_native_autocommit(enabled) { + return false; + } + self.configured_autocommit = enabled; + self.auto_commit = enabled; + self.in_transaction = false; + true + } + + /// Writes isolation-level or lock-timeout CCI database parameters. + pub fn set_attribute(&mut self, attribute: i64, value: i64) -> bool { + match attribute { + 0 => return self.set_autocommit(value != 0), + 2 => { + if value == 0 || (value < 0 && value != -1) { + return false; + } + self.query_timeout = value; + return true; + } + 1000 | 1001 => {} + _ => return false, + } + let mut error = NativeError::default(); + let result = unsafe { + if attribute == 1000 { + (api().expect("CCI loaded").set_isolation_level)(self.handle, value as i32, &mut error) + } else { + (api().expect("CCI loaded").set_lock_timeout)(self.handle, value as i32, &mut error) + } + }; + if result < 0 { + self.error = native_error(result, &error); + false + } else { + true + } + } + + /// Reads autocommit and PDO_CUBRID's three connection attributes. + pub fn attribute(&mut self, attribute: i64) -> Option { + if attribute == 0 { + return Some(self.auto_commit as i64); + } + if attribute == 2 { + return Some(self.query_timeout); + } + let parameter = match attribute { + 1000 => 1, + 1001 => 2, + 1002 => 3, + _ => return None, + }; + let mut value = 0i32; + let mut error = NativeError::default(); + let result = unsafe { + (api().expect("CCI loaded").get_db_parameter)( + self.handle, + parameter, + (&mut value as *mut i32).cast(), + &mut error, + ) + }; + if result < 0 { + if attribute == 1002 { + return Some(0); + } + self.error = native_error(result, &error); + None + } else { + Some(value as i64) + } + } + + /// Returns CCI's client version. + pub fn client_version(&self) -> String { + let mut major = 0; + let mut minor = 0; + let mut patch = 0; + unsafe { (api().expect("CCI loaded").version)(&mut major, &mut minor, &mut patch) }; + format!("{major}.{minor}.{patch}") + } + + /// Returns the connected CUBRID server version. + pub fn server_version(&mut self) -> String { + let mut buffer: [c_char; 128] = [0; 128]; + let result = unsafe { + (api().expect("CCI loaded").get_db_version)( + self.handle, + buffer.as_mut_ptr(), + buffer.len() as i32, + ) + }; + if result < 0 { + String::new() + } else { + copy_c_string(buffer.as_ptr()) + } + } + + /// Returns PDO_CUBRID's textual last inserted identifier. + pub fn last_insert_id(&mut self) -> String { + let mut pointer: *mut c_char = ptr::null_mut(); + let mut error = NativeError::default(); + let result = unsafe { + (api().expect("CCI loaded").last_insert_id)( + self.handle, + (&mut pointer as *mut *mut c_char).cast(), + &mut error, + ) + }; + if result < 0 { + self.error = native_error(result, &error); + String::new() + } else { + copy_c_string(pointer) + } + } + + /// Escapes exact bytes through CCI's connection-aware PDO_CUBRID quoter. + pub fn quote(&mut self, input: &[u8]) -> Result, String> { + let mut output = vec![0u8; input.len().saturating_mul(2).saturating_add(18)]; + let mut error = NativeError::default(); + let result = unsafe { + (api()?.escape_string)( + self.handle, + output.as_mut_ptr().cast(), + input.as_ptr().cast(), + input.len() as c_ulong, + &mut error, + ) + }; + if result < 0 { + self.error = native_error(result as i32, &error); + return Err(self.error.message.clone()); + } + output.truncate(result as usize); + Ok(output) + } + + /// Returns the current connection SQLSTATE. + pub fn sqlstate(&self) -> &str { + &self.error.sqlstate + } + + /// Returns the current connection native code. + pub fn errcode(&self) -> i64 { + self.error.code + } + + /// Returns the current connection diagnostic text. + pub fn errmsg(&self) -> &str { + &self.error.message + } +} + +#[derive(Clone)] +enum BindValue { + Null, + Int(i32), + BigInt(i64), + Double(f64), + Text(Vec), + Lob(Vec, i32), + TypedText(Vec, i32), + Bit(Vec, i32), + Set(Vec>, i32), +} + +#[derive(Clone)] +struct Column { + name: String, + table: String, + default_value: String, + native_type: String, + ext_type: u8, + precision: i64, + scale: i64, + flags: i64, +} + +/// Owns one prepared CCI request and its materialized result sets. +pub struct CubridStmt { + pub conn_id: i64, + request: i32, + named_map: HashMap, + order: Vec, + binds: Vec, + bound: Vec, + columns: Vec, + rows: Vec>>>, + cursor: isize, + executed: bool, + row_count: i64, + error: ErrorState, +} + +unsafe impl Send for CubridStmt {} + +impl Drop for CubridStmt { + /// Closes the native CCI request handle. + fn drop(&mut self) { + if self.request > 0 { + if let Ok(api) = api() { + unsafe { (api.close_request)(self.request) }; + } + } + } +} + +impl CubridStmt { + /// Prepares SQL with PDO named-placeholder normalization. + pub fn new(connection: &mut CubridConn, conn_id: i64, sql: &str) -> Result { + let (translated, named_map, order, mixed) = crate::my::translate_placeholders(sql, false); + if mixed { + return Err("Invalid parameter number: mixed named and positional parameters".to_string()); + } + let sql = c_string(&translated)?; + let mut error = NativeError::default(); + let request = unsafe { + (api()?.prepare)(connection.handle, sql.as_ptr(), 0, &mut error) + }; + if request < 0 { + connection.error = native_error(request, &error); + return Err(connection.error.message.clone()); + } + if connection.query_timeout != -1 && connection.query_timeout != 0 { + let timeout_ms = connection.query_timeout.saturating_mul(1000); + let timeout_ms = i32::try_from(timeout_ms).unwrap_or(i32::MAX); + let result = unsafe { (api()?.set_query_timeout)(request, timeout_ms) }; + if result < 0 { + unsafe { (api()?.close_request)(request) }; + connection.error = ErrorState { + sqlstate: "HY000".to_string(), + code: result as i64, + message: format!("CCI, CCI query-timeout error {result}"), + }; + return Err(connection.error.message.clone()); + } + } + let slots = order.iter().copied().max().unwrap_or(0).max(0) as usize; + Ok(Self { + conn_id, + request, + named_map, + order, + binds: vec![BindValue::Null; slots], + bound: vec![false; slots], + columns: Vec::new(), + rows: Vec::new(), + cursor: -1, + executed: false, + row_count: 0, + error: ErrorState::default(), + }) + } + + /// Wraps a CCI schema-information request as an already executed statement. + pub fn schema( + connection: &mut CubridConn, + conn_id: i64, + schema_type: i64, + class_name: &str, + attribute_name: &str, + ) -> Result { + let class_name = (!class_name.is_empty()) + .then(|| c_string(class_name)) + .transpose()?; + let attribute_name = (!attribute_name.is_empty()) + .then(|| c_string(attribute_name)) + .transpose()?; + let flag = match schema_type { + 1 | 2 => 1, + 4 | 5 | 20 => 2, + _ => 0, + }; + let mut error = NativeError::default(); + let request = unsafe { + (api()?.schema)( + connection.handle, + schema_type as i32, + class_name + .as_ref() + .map_or(ptr::null_mut(), |value| value.as_ptr().cast_mut()), + attribute_name + .as_ref() + .map_or(ptr::null_mut(), |value| value.as_ptr().cast_mut()), + flag, + &mut error, + ) + }; + if request < 0 { + connection.error = native_error(request, &error); + return Err(connection.error.message.clone()); + } + let mut statement = Self { + conn_id, + request, + named_map: HashMap::new(), + order: Vec::new(), + binds: Vec::new(), + bound: Vec::new(), + columns: Vec::new(), + rows: Vec::new(), + cursor: -1, + executed: true, + row_count: 0, + error: ErrorState::default(), + }; + statement.materialize(connection)?; + Ok(statement) + } + + /// Resolves one named placeholder to its 1-based bind slot. + pub fn parameter_index(&self, name: &str) -> i64 { + self.named_map + .get(name.trim_start_matches(':')) + .copied() + .unwrap_or(0) + } + + /// Stores one bind value after validating its 1-based slot. + fn bind(&mut self, index: i64, value: BindValue) -> bool { + let Some(slot) = usize::try_from(index).ok().and_then(|index| index.checked_sub(1)) else { + return false; + }; + if slot >= self.binds.len() { + return false; + } + self.binds[slot] = value; + self.bound[slot] = true; + true + } + + /// Binds an integer using CCI's native integer widths. + pub fn bind_int(&mut self, index: i64, value: i64) -> bool { + if let Ok(value) = i32::try_from(value) { + self.bind(index, BindValue::Int(value)) + } else { + self.bind(index, BindValue::BigInt(value)) + } + } + + /// Binds a floating-point value. + pub fn bind_double(&mut self, index: i64, value: f64) -> bool { + self.bind(index, BindValue::Double(value)) + } + + /// Binds exact text bytes. + pub fn bind_text(&mut self, index: i64, value: Vec) -> bool { + self.bind(index, BindValue::Text(value)) + } + + /// Binds exact BLOB bytes through CCI's LOB API. + pub fn bind_blob(&mut self, index: i64, value: Vec) -> bool { + self.bind(index, BindValue::Lob(value, CCI_U_TYPE_BLOB)) + } + + /// Stores a PDO_CUBRID driver-option scalar or collection bind. + pub fn bind_typed( + &mut self, + index: i64, + value: Vec, + type_name: &str, + is_set: bool, + pdo_type: i64, + ) -> bool { + let mut domain = if type_name.is_empty() { + CCI_U_TYPE_STRING + } else if let Some(domain) = named_type(type_name) { + domain + } else { + self.error = ErrorState { + sqlstate: "HY000".to_string(), + code: -30_008, + message: "CLIENT, Not supported type".to_string(), + }; + return false; + }; + if domain == 25 && !is_set { + domain = match pdo_type { + 0 => CCI_U_TYPE_NULL, + 1 => CCI_U_TYPE_INT, + 3 => CCI_U_TYPE_BLOB, + _ => CCI_U_TYPE_STRING, + }; + } + let binding = if is_set { + let Some(values) = decode_set(&value) else { + return false; + }; + let element_type = if domain == CCI_U_TYPE_BIT || domain == CCI_U_TYPE_VARBIT { + domain + } else { + CCI_U_TYPE_STRING + }; + BindValue::Set(values, element_type) + } else { + match domain { + CCI_U_TYPE_NULL => BindValue::Null, + CCI_U_TYPE_BLOB | CCI_U_TYPE_CLOB => BindValue::Lob(value, domain), + CCI_U_TYPE_BIT | CCI_U_TYPE_VARBIT => BindValue::Bit(value, domain), + _ => BindValue::TypedText(value, domain), + } + }; + self.bind(index, binding) + } + + /// Binds SQL NULL. + pub fn bind_null(&mut self, index: i64) -> bool { + self.bind(index, BindValue::Null) + } + + /// Clears cursor/result state while retaining native preparation and binds. + pub fn reset(&mut self) { + if self.executed { + if let Ok(api) = api() { + unsafe { (api.fetch_buffer_clear)(self.request) }; + } + } + self.columns.clear(); + self.rows.clear(); + self.cursor = -1; + self.executed = false; + self.row_count = 0; + } + + /// Clears all values as PDO does before `execute(array)` replaces them. + pub fn clear_bindings(&mut self) { + self.reset(); + self.binds.fill(BindValue::Null); + self.bound.fill(false); + } + + /// Reports whether this request has not yet run since reset. + pub fn needs_execute(&self) -> bool { + !self.executed + } + + /// Binds every occurrence, executes CCI, and materializes the active result. + pub fn execute(&mut self, connection: &mut CubridConn) -> Result<(), String> { + let cleared = unsafe { (api()?.fetch_buffer_clear)(self.request) }; + if cleared < 0 { + self.error = ErrorState { + sqlstate: "HY000".to_string(), + code: cleared as i64, + message: format!("CCI, CCI fetch-buffer error {cleared}"), + }; + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + if self.bound.iter().any(|bound| !bound) { + self.error = ErrorState { + sqlstate: "HY000".to_string(), + code: -30_017, + message: "CLIENT, Param not bind".to_string(), + }; + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + let api = api()?; + let mut lob_handles = Vec::new(); + for (occurrence, slot) in self.order.iter().enumerate() { + let slot = (*slot as usize).saturating_sub(1); + let result = match &mut self.binds[slot] { + BindValue::Null => unsafe { + (api.bind)(self.request, occurrence as i32 + 1, CCI_A_TYPE_STR, ptr::null_mut(), CCI_U_TYPE_NULL, 0) + }, + BindValue::Int(value) => unsafe { + (api.bind)(self.request, occurrence as i32 + 1, CCI_A_TYPE_INT, (value as *mut i32).cast(), CCI_U_TYPE_INT, 0) + }, + BindValue::BigInt(value) => unsafe { + (api.bind)(self.request, occurrence as i32 + 1, CCI_A_TYPE_BIGINT, (value as *mut i64).cast(), CCI_U_TYPE_BIGINT, 0) + }, + BindValue::Double(value) => unsafe { + (api.bind)(self.request, occurrence as i32 + 1, CCI_A_TYPE_DOUBLE, (value as *mut f64).cast(), CCI_U_TYPE_DOUBLE, 0) + }, + BindValue::Text(value) => { + value.push(0); + let result = unsafe { + (api.bind)(self.request, occurrence as i32 + 1, CCI_A_TYPE_STR, value.as_mut_ptr().cast(), CCI_U_TYPE_STRING, 0) + }; + value.pop(); + result + } + BindValue::TypedText(value, domain) => { + value.push(0); + let result = unsafe { + (api.bind)(self.request, occurrence as i32 + 1, CCI_A_TYPE_STR, value.as_mut_ptr().cast(), *domain, 0) + }; + value.pop(); + result + } + BindValue::Bit(value, domain) => { + let mut bit = NativeBit { + size: value.len() as i32, + buffer: value.as_mut_ptr().cast(), + }; + unsafe { + (api.bind)(self.request, occurrence as i32 + 1, CCI_A_TYPE_BIT, (&mut bit as *mut NativeBit).cast(), *domain, 0) + } + } + BindValue::Lob(value, domain) => { + let mut lob = ptr::null_mut(); + let mut error = NativeError::default(); + let created = unsafe { + if *domain == CCI_U_TYPE_BLOB { + (api.blob_new)(connection.handle, &mut lob, &mut error) + } else { + (api.clob_new)(connection.handle, &mut lob, &mut error) + } + }; + if created < 0 { + self.error = native_error(created, &error); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + let written = unsafe { if *domain == CCI_U_TYPE_BLOB { + (api.blob_write)(connection.handle, lob, 0, value.len() as i32, value.as_ptr().cast(), &mut error) + } else { + (api.clob_write)(connection.handle, lob, 0, value.len() as i32, value.as_ptr().cast(), &mut error) + } }; + if written < 0 { + unsafe { if *domain == CCI_U_TYPE_BLOB { (api.blob_free)(lob) } else { (api.clob_free)(lob) } }; + self.error = native_error(written, &error); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + lob_handles.push((lob, *domain)); + unsafe { + let a_type = if *domain == CCI_U_TYPE_BLOB { CCI_A_TYPE_BLOB } else { CCI_A_TYPE_CLOB }; + (api.bind)(self.request, occurrence as i32 + 1, a_type, lob, *domain, 1) + } + } + BindValue::Set(values, element_type) => { + let mut set = ptr::null_mut(); + let mut indicators = values.iter().map(|value| i32::from(value == b"NULL")).collect::>(); + let result = if *element_type == CCI_U_TYPE_BIT || *element_type == CCI_U_TYPE_VARBIT { + let mut bits = values.iter_mut().map(|value| NativeBit { + size: value.len() as i32, + buffer: value.as_mut_ptr().cast(), + }).collect::>(); + unsafe { (api.set_make)(&mut set, *element_type, bits.len() as i32, bits.as_mut_ptr().cast(), indicators.as_mut_ptr()) } + } else { + let mut strings = values.iter_mut().map(|value| { + if let Some(nul) = value.iter().position(|byte| *byte == 0) { + value.truncate(nul); + } + value.push(0); + value.as_mut_ptr().cast::() + }).collect::>(); + unsafe { (api.set_make)(&mut set, CCI_U_TYPE_STRING, strings.len() as i32, strings.as_mut_ptr().cast(), indicators.as_mut_ptr()) } + }; + if result < 0 { + result + } else { + let bound = unsafe { (api.bind)(self.request, occurrence as i32 + 1, CCI_A_TYPE_SET, set, CCI_U_TYPE_SET, 0) }; + unsafe { (api.set_free)(set) }; + bound + } + } + }; + if result < 0 { + self.error = ErrorState { + sqlstate: "HY000".to_string(), + code: result as i64, + message: format!("CCI, CCI bind error {result}"), + }; + connection.error = self.error.clone(); + for (lob, domain) in lob_handles { + unsafe { if domain == CCI_U_TYPE_BLOB { (api.blob_free)(lob) } else { (api.clob_free)(lob) } }; + } + return Err(self.error.message.clone()); + } + } + let mut error = NativeError::default(); + let result = unsafe { (api.execute)(self.request, CCI_EXEC_QUERY_ALL, 0, &mut error) }; + for (lob, domain) in lob_handles { + unsafe { if domain == CCI_U_TYPE_BLOB { (api.blob_free)(lob) } else { (api.clob_free)(lob) } }; + } + if result < 0 { + self.error = native_error(result, &error); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + self.executed = true; + self.row_count = result as i64; + connection.changes = self.row_count; + self.materialize(connection) + } + + /// Copies active CCI metadata and all rows before the next native result replaces them. + fn materialize(&mut self, connection: &mut CubridConn) -> Result<(), String> { + self.columns.clear(); + self.rows.clear(); + self.cursor = -1; + let api = api()?; + let mut statement_type = -1; + let mut count = 0; + let metadata = unsafe { (api.result_info)(self.request, &mut statement_type, &mut count) }; + if count > 0 && metadata.is_null() { + self.error = ErrorState { + sqlstate: "HY000".to_string(), + code: -30_003, + message: "CLIENT, Cannot get column info".to_string(), + }; + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + for index in 0..count.max(0) as usize { + let native = unsafe { &*metadata.add(index) }; + let mut flags = 0i64; + flags |= i64::from(native.is_non_null != 0); + flags |= i64::from(native.is_auto_increment != 0) << 1; + flags |= i64::from(native.is_unique_key != 0) << 2; + flags |= i64::from(native.is_primary_key != 0) << 3; + flags |= i64::from(native.is_foreign_key != 0) << 4; + flags |= i64::from(native.is_reverse_index != 0) << 5; + flags |= i64::from(native.is_reverse_unique != 0) << 6; + self.columns.push(Column { + name: copy_c_string(native.col_name), + table: copy_c_string(native.class_name), + default_value: copy_c_string(native.default_value), + native_type: native_type_name(native.ext_type, native.precision, native.scale), + ext_type: native.ext_type, + precision: native.precision as i64, + scale: native.scale as i64, + flags, + }); + } + if statement_type == CUBRID_STMT_INSERT + || statement_type == CUBRID_STMT_UPDATE + || statement_type == CUBRID_STMT_DELETE + { + connection.changes = self.row_count; + } + if statement_type == CUBRID_STMT_SELECT || count > 0 { + loop { + let mut error = NativeError::default(); + let positioned = unsafe { (api.cursor)(self.request, 1, CCI_CURSOR_CURRENT, &mut error) }; + if positioned == CCI_ER_NO_MORE_DATA { + break; + } + if positioned < 0 { + self.error = native_error(positioned, &error); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + let fetched = unsafe { (api.fetch)(self.request, &mut error) }; + if fetched < 0 { + self.error = native_error(fetched, &error); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + let mut row = Vec::with_capacity(self.columns.len()); + for (index, column) in self.columns.iter().enumerate() { + row.push(Self::read_column(api, connection.handle, self.request, index, column)?); + } + self.rows.push(row); + } + self.row_count = self.rows.len() as i64; + } + Ok(()) + } + + /// Copies one scalar or LOB result cell out of CCI-owned memory. + fn read_column( + api: &CciApi, + connection: i32, + request: i32, + index: usize, + column: &Column, + ) -> Result>, String> { + let domain = collection_domain(column.ext_type); + if domain == CCI_U_TYPE_BLOB as u8 || domain == CCI_U_TYPE_CLOB as u8 { + let mut lob: *mut c_void = ptr::null_mut(); + let mut indicator = 0; + let a_type = if domain == CCI_U_TYPE_BLOB as u8 { CCI_A_TYPE_BLOB } else { CCI_A_TYPE_CLOB }; + let result = unsafe { + (api.get_data)(request, index as i32 + 1, a_type, (&mut lob as *mut *mut c_void).cast(), &mut indicator) + }; + if result < 0 { + return Err(format!("CCI, CCI get-data error {result}")); + } + if indicator < 0 || lob.is_null() { + return Ok(None); + } + let size = unsafe { if domain == CCI_U_TYPE_BLOB as u8 { (api.blob_size)(lob) } else { (api.clob_size)(lob) } }; + let mut output = vec![0u8; usize::try_from(size.max(0)).unwrap_or(0)]; + if output.is_empty() { + unsafe { if domain == CCI_U_TYPE_BLOB as u8 { (api.blob_free)(lob) } else { (api.clob_free)(lob) } }; + return Ok(Some(output)); + } + let mut error = NativeError::default(); + let read = unsafe { + if domain == CCI_U_TYPE_BLOB as u8 { + (api.blob_read)(connection, lob, 0, output.len() as i32, output.as_mut_ptr().cast(), &mut error) + } else { + (api.clob_read)(connection, lob, 0, output.len() as i32, output.as_mut_ptr().cast(), &mut error) + } + }; + unsafe { if domain == CCI_U_TYPE_BLOB as u8 { (api.blob_free)(lob) } else { (api.clob_free)(lob) } }; + if read < 0 { + return Err(native_error(read, &error).message); + } + output.truncate(usize::try_from(read).unwrap_or(0).min(output.len())); + return Ok(Some(output)); + } + let mut pointer: *mut c_char = ptr::null_mut(); + let mut indicator = 0; + let result = unsafe { + (api.get_data)(request, index as i32 + 1, CCI_A_TYPE_STR, (&mut pointer as *mut *mut c_char).cast(), &mut indicator) + }; + if result < 0 { + return Err(format!("CCI, CCI get-data error {result}")); + } + if indicator < 0 || pointer.is_null() { + Ok(None) + } else { + Ok(Some(unsafe { std::slice::from_raw_parts(pointer.cast::(), indicator as usize) }.to_vec())) + } + } + + /// Advances to the next materialized row. + pub fn step(&mut self) -> i64 { + let next = self.cursor + 1; + if next < self.rows.len() as isize { + self.cursor = next; + 1 + } else { + 0 + } + } + + /// Selects one materialized row using PDO fetch-orientation constants. + pub fn step_oriented(&mut self, orientation: i64, offset: i64) -> i64 { + let target = match orientation { + 0 => self.cursor + 1, + 1 => self.cursor - 1, + 2 => 0, + 3 => self.rows.len() as isize - 1, + 4 if offset > 0 => match isize::try_from(offset - 1) { + Ok(offset) => offset, + Err(_) => return 0, + }, + 4 => return 0, + 5 => self.cursor + offset as isize, + _ => return 0, + }; + if target < 0 || target >= self.rows.len() as isize { + 0 + } else { + self.cursor = target; + 1 + } + } + + /// Advances to and materializes CCI's next result set. + pub fn next_rowset(&mut self, connection: &mut CubridConn) -> bool { + let mut error = NativeError::default(); + let result = unsafe { (api().expect("CCI loaded").next_result)(self.request, &mut error) }; + if result == CAS_ER_NO_MORE_RESULT_SET { + return false; + } + if result < 0 { + self.error = native_error(result, &error); + connection.error = self.error.clone(); + return false; + } + self.row_count = result as i64; + self.materialize(connection).is_ok() + } + + /// Returns the affected/result row count reported by CCI. + pub fn row_count(&self) -> i64 { + self.row_count + } + + /// Returns the active result column count. + pub fn column_count(&self) -> i64 { + self.columns.len() as i64 + } + + /// Returns one active result column name. + pub fn column_name(&self, index: i64) -> String { + usize::try_from(index).ok().and_then(|index| self.columns.get(index)).map(|column| column.name.clone()).unwrap_or_default() + } + + /// Returns PDO's text, LOB, or NULL storage tag for one current cell. + pub fn column_type(&self, index: i64) -> i64 { + if self.cell(index).is_none_or(Option::is_none) { + return 5; + } + let domain = usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .map(|column| collection_domain(column.ext_type)) + .unwrap_or_default(); + if domain == CCI_U_TYPE_BLOB as u8 || domain == CCI_U_TYPE_CLOB as u8 { 4 } else { 3 } + } + + /// Returns one current value parsed as an integer. + pub fn column_int(&self, index: i64) -> i64 { + String::from_utf8_lossy(&self.column_data(index)).parse().unwrap_or(0) + } + + /// Returns one current value parsed as a double. + pub fn column_double(&self, index: i64) -> f64 { + String::from_utf8_lossy(&self.column_data(index)).parse().unwrap_or(0.0) + } + + /// Returns one current value's exact bytes. + pub fn column_data(&self, index: i64) -> Vec { + self.cell(index).and_then(Option::as_ref).cloned().unwrap_or_default() + } + + /// Returns one current row cell. + fn cell(&self, index: i64) -> Option<&Option>> { + let row = usize::try_from(self.cursor).ok().and_then(|row| self.rows.get(row))?; + usize::try_from(index).ok().and_then(|index| row.get(index)) + } + + /// Returns the native PDO_CUBRID type string. + pub fn column_native_type(&self, index: i64) -> String { + usize::try_from(index).ok().and_then(|index| self.columns.get(index)).map(|column| column.native_type.clone()).unwrap_or_default() + } + + /// Returns the source class/table name. + pub fn column_table_name(&self, index: i64) -> String { + usize::try_from(index).ok().and_then(|index| self.columns.get(index)).map(|column| column.table.clone()).unwrap_or_default() + } + + /// Returns the PDO_CUBRID metadata default value. + pub fn column_default(&self, index: i64) -> String { + usize::try_from(index).ok().and_then(|index| self.columns.get(index)).map(|column| column.default_value.clone()).unwrap_or_default() + } + + /// Returns the declared CUBRID precision. + pub fn column_precision(&self, index: i64) -> i64 { + usize::try_from(index).ok().and_then(|index| self.columns.get(index)).map(|column| column.precision).unwrap_or_default() + } + + /// Returns the declared CUBRID scale. + pub fn column_scale(&self, index: i64) -> i64 { + usize::try_from(index).ok().and_then(|index| self.columns.get(index)).map(|column| column.scale).unwrap_or_default() + } + + /// Returns packed not-null/key/index metadata flags. + pub fn column_flags(&self, index: i64) -> i64 { + usize::try_from(index).ok().and_then(|index| self.columns.get(index)).map(|column| column.flags).unwrap_or_default() + } + + /// Returns the statement SQLSTATE. + pub fn sqlstate(&self) -> &str { + &self.error.sqlstate + } + + /// Returns the statement native code. + pub fn errcode(&self) -> i64 { + self.error.code + } + + /// Returns the statement diagnostic text. + pub fn errmsg(&self) -> &str { + &self.error.message + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Parses defaults, credentials, and pass-through CCI URL options. + #[test] + fn parses_cubrid_dsn() { + let dsn = parse_dsn("cubrid:host=db;port=33000;dbname=app;user=scott;password=t%3Bger;althosts=db2%25").unwrap(); + assert_eq!(dsn.user, "scott"); + assert_eq!(dsn.password, "t;ger"); + assert_eq!(dsn.url, "cci:CUBRID:db:33000:app:scott:t;ger:?althosts=db2%25"); + } + + /// Matches upstream's native metadata spelling for representative types. + #[test] + fn formats_native_types() { + assert_eq!(native_type_name(2, 40, 0), "varchar(40)"); + assert_eq!(native_type_name(7, 10, 2), "numeric(10,2)"); + assert_eq!(native_type_name(23, 0, 0), "blob"); + assert_eq!(native_type_name(0x22, 40, 0), "set(varchar(40))"); + assert_eq!(native_type_name(0x82, 0, 0), "[unknown]"); + assert_eq!(named_type("unknown"), None); + } + + /// Decodes empty and byte-containing collection frames without delimiter ambiguity. + #[test] + fn decodes_set_frames() { + assert_eq!(decode_set(b"0:").unwrap(), Vec::>::new()); + assert_eq!(decode_set(b"3:1:a0:3:b:c").unwrap(), vec![b"a".to_vec(), Vec::new(), b"b:c".to_vec()]); + assert!(decode_set(b"1:4:abc").is_none()); + } + + /// Preserves invalid percent sequences while decoding valid credentials. + #[test] + fn decodes_credentials_losslessly() { + assert_eq!(decode_component("a%20b%ZZ"), "a b%ZZ"); + } + +} diff --git a/crates/elephc-pdo/src/dblib.rs b/crates/elephc-pdo/src/dblib.rs new file mode 100644 index 0000000000..41f6a3797e --- /dev/null +++ b/crates/elephc-pdo/src/dblib.rs @@ -0,0 +1,1539 @@ +//! Purpose: +//! FreeTDS DB-Library backend matching php-src's `pdo_dblib` driver. +//! +//! Called from: +//! - The bridge root when built with the optional `dblib` feature. +//! +//! Key details: +//! - Calls the same `libsybdb` client API as PHP instead of reimplementing TDS. +//! - Materializes DB-Library rowsets so bridge handles remain independent and safe. +//! - PDO placeholders are emulated client-side because DB-Library has no prepare API. + +use std::collections::HashMap; +use std::ffi::{c_char, c_int, c_uchar, CStr, CString}; +use std::ptr; +use std::sync::{Mutex, Once, OnceLock}; + +const SUCCEED: c_int = 1; +const FAIL: c_int = 0; +const NO_MORE_ROWS: c_int = -2; +const NO_MORE_RESULTS: c_int = 2; +const INT_CANCEL: c_int = 2; + +const DBSETUSER: c_int = 2; +const DBSETPWD: c_int = 3; +const DBSETAPP: c_int = 5; +const DBSETCHARSET: c_int = 10; +const DBSETDBNAME: c_int = 14; +const DBTEXTSIZE: c_int = 17; +const DBQUOTEDIDENT: c_int = 35; + +const SYBIMAGE: c_int = 34; +const SYBTEXT: c_int = 35; +const SYBUNIQUE: c_int = 36; +const SYBVARBINARY: c_int = 37; +const SYBINTN: c_int = 38; +const SYBVARCHAR: c_int = 39; +const SYBMSDATE: c_int = 40; +const SYBMSTIME: c_int = 41; +const SYBMSDATETIME2: c_int = 42; +const SYBMSDATETIMEOFFSET: c_int = 43; +const SYBBINARY: c_int = 45; +const SYBCHAR: c_int = 47; +const SYBINT1: c_int = 48; +const SYBBIT: c_int = 50; +const SYBINT2: c_int = 52; +const SYBINT4: c_int = 56; +const SYBDATETIME4: c_int = 58; +const SYBREAL: c_int = 59; +const SYBMONEY: c_int = 60; +const SYBDATETIME: c_int = 61; +const SYBFLT8: c_int = 62; +const SYBNTEXT: c_int = 99; +const SYBBITN: c_int = 104; +const SYBDECIMAL: c_int = 106; +const SYBNUMERIC: c_int = 108; +const SYBFLTN: c_int = 109; +const SYBMONEYN: c_int = 110; +const SYBINT8: c_int = 127; +const SYBNVARCHAR: c_int = 103; +const SYBMONEY4: c_int = 122; + +/// FreeTDS's Sybase-layout broken-down date record used by `dbdatecrack`. +#[repr(C)] +struct DbDateRec2 { + dateyear: c_int, + quarter: c_int, + datemonth: c_int, + datedmonth: c_int, + datedyear: c_int, + week: c_int, + datedweek: c_int, + datehour: c_int, + dateminute: c_int, + datesecond: c_int, + datensecond: c_int, + datetzone: c_int, +} + +/// FreeTDS's two-word classic datetime representation accepted by `dbdatecrack`. +#[repr(C)] +struct DbDateTime { + days: c_int, + time: c_int, +} + +#[repr(C)] +struct DbProcess { + _private: [u8; 0], +} + +#[repr(C)] +struct LoginRecord { + _private: [u8; 0], +} + +/// FreeTDS precision/scale descriptor returned for one result column. +#[repr(C)] +struct DbTypeInfo { + precision: c_int, + scale: c_int, +} + +type ErrorHandler = unsafe extern "C" fn( + *mut DbProcess, + c_int, + c_int, + c_int, + *mut c_char, + *mut c_char, +) -> c_int; + +type MessageHandler = unsafe extern "C" fn( + *mut DbProcess, + c_int, + c_int, + c_int, + *mut c_char, + *mut c_char, + *mut c_char, + c_int, +) -> c_int; + +#[link(name = "sybdb")] +extern "C" { + fn dbinit() -> c_int; + fn dblogin() -> *mut LoginRecord; + fn dbloginfree(login: *mut LoginRecord); + fn dbsetlname(login: *mut LoginRecord, value: *const c_char, which: c_int) -> c_int; + fn dbsetlversion(login: *mut LoginRecord, version: c_uchar) -> c_int; + fn dbsetlogintime(seconds: c_int) -> c_int; + fn dbsettime(seconds: c_int) -> c_int; + fn dbopen(login: *mut LoginRecord, server: *const c_char) -> *mut DbProcess; + fn dbclose(process: *mut DbProcess); + fn dbdead(process: *mut DbProcess) -> c_int; + fn dbcmd(process: *mut DbProcess, sql: *const c_char) -> c_int; + fn dbsqlexec(process: *mut DbProcess) -> c_int; + fn dbresults(process: *mut DbProcess) -> c_int; + fn dbnextrow(process: *mut DbProcess) -> c_int; + fn dbnumcols(process: *mut DbProcess) -> c_int; + fn dbcolname(process: *mut DbProcess, column: c_int) -> *mut c_char; + fn dbcoltype(process: *mut DbProcess, column: c_int) -> c_int; + fn dbcollen(process: *mut DbProcess, column: c_int) -> c_int; + fn dbcolsource(process: *mut DbProcess, column: c_int) -> *mut c_char; + fn dbcoltypeinfo(process: *mut DbProcess, column: c_int) -> *mut DbTypeInfo; + fn dbcolutype(process: *mut DbProcess, column: c_int) -> c_int; + fn dbdata(process: *mut DbProcess, column: c_int) -> *mut c_uchar; + fn dbdatlen(process: *mut DbProcess, column: c_int) -> c_int; + fn dbcount(process: *mut DbProcess) -> c_int; + fn dbcancel(process: *mut DbProcess) -> c_int; + fn dbsetopt( + process: *mut DbProcess, + option: c_int, + char_parameter: *const c_char, + int_parameter: c_int, + ) -> c_int; + fn dbconvert( + process: *mut DbProcess, + source_type: c_int, + source: *const c_uchar, + source_len: c_int, + dest_type: c_int, + dest: *mut c_uchar, + dest_len: c_int, + ) -> c_int; + fn dbdatecrack( + process: *mut DbProcess, + record: *mut DbDateRec2, + datetime: *mut DbDateTime, + ) -> c_int; + fn dbsetuserdata(process: *mut DbProcess, data: *mut c_uchar); + fn dbgetuserdata(process: *mut DbProcess) -> *mut c_uchar; + fn dberrhandle(handler: Option) -> Option; + fn dbmsghandle(handler: Option) -> Option; + fn dbversion() -> *const c_char; + fn dbtds(process: *mut DbProcess) -> c_int; +} + +/// Native DB-Library diagnostic state for one connection/statement operation. +#[derive(Clone, Default)] +struct ErrorState { + sqlstate: String, + native_code: i64, + message: String, + os_code: i64, + severity: i64, + os_message: String, +} + +/// Last diagnostic raised before DB-Library has produced a connection handle. +fn open_error() -> &'static Mutex { + static ERROR: OnceLock> = OnceLock::new(); + ERROR.get_or_init(|| Mutex::new(ErrorState::default())) +} + +/// Converts a possibly-null DB-Library C string into an owned Rust string. +unsafe fn owned_cstr(value: *const c_char) -> String { + if value.is_null() { + String::new() + } else { + CStr::from_ptr(value).to_string_lossy().into_owned() + } +} + +/// Maps DB-Library client codes to php-src's PDO_DBLIB SQLSTATE classes. +fn sqlstate_for_db_error(db_error: c_int) -> &'static str { + match db_error { + 20017 | 20002 => "01002", + 20010 => "HY001", + 20014 => "28000", + _ => "HY000", + } +} + +/// Receives DB-Library client errors and stores the SQLSTATE/native diagnostic. +unsafe extern "C" fn error_handler( + process: *mut DbProcess, + severity: c_int, + db_error: c_int, + os_error: c_int, + db_message: *mut c_char, + os_message: *mut c_char, +) -> c_int { + let sqlstate = sqlstate_for_db_error(db_error); + let mut message = owned_cstr(db_message); + let os = owned_cstr(os_message); + if !os.is_empty() { + if !message.is_empty() { + message.push_str(": "); + } + message.push_str(&os); + } + if !process.is_null() { + let state = dbgetuserdata(process) as *mut ErrorState; + if !state.is_null() { + (*state).sqlstate = sqlstate.to_string(); + (*state).native_code = i64::from(db_error); + (*state).message = message; + (*state).os_code = i64::from(os_error); + (*state).severity = i64::from(severity); + (*state).os_message = os; + return INT_CANCEL; + } + } + if let Ok(mut state) = open_error().lock() { + state.sqlstate = sqlstate.to_string(); + state.native_code = i64::from(db_error); + state.message = message; + state.os_code = i64::from(os_error); + state.severity = i64::from(severity); + state.os_message = os; + } + INT_CANCEL +} + +/// Receives server messages and retains messages with a non-zero severity. +unsafe extern "C" fn message_handler( + process: *mut DbProcess, + message_number: c_int, + _message_state: c_int, + severity: c_int, + message: *mut c_char, + _server: *mut c_char, + _procedure: *mut c_char, + _line: c_int, +) -> c_int { + if severity == 0 || process.is_null() { + return 0; + } + let state = dbgetuserdata(process) as *mut ErrorState; + if !state.is_null() { + let state = &mut *state; + let _ = message_number; + state.message = owned_cstr(message); + if state.sqlstate.is_empty() { + state.sqlstate = "HY000".to_string(); + } + } + 0 +} + +/// Initializes FreeTDS and installs process-global DB-Library callbacks once. +fn initialize() -> Result<(), String> { + static INIT: Once = Once::new(); + static RESULT: OnceLock> = OnceLock::new(); + INIT.call_once(|| unsafe { + let result = if dbinit() == FAIL { + Err("PDO_DBLIB: dbinit() failed".to_string()) + } else { + dberrhandle(Some(error_handler)); + dbmsghandle(Some(message_handler)); + Ok(()) + }; + let _ = RESULT.set(result); + }); + RESULT + .get() + .cloned() + .unwrap_or_else(|| Err("PDO_DBLIB: initialization failed".to_string())) +} + +/// Parsed DBLIB DSN fields with php-src-compatible defaults. +struct DsnOptions { + host: String, + port: Option, + dbname: Option, + user: Option, + password: Option, + charset: Option, + appname: String, + version: Option, + connection_timeout: i32, + query_timeout: i32, + stringify_uniqueidentifier: bool, + skip_empty_rowsets: bool, + datetime_convert: bool, +} + +/// Decodes the narrow percent encoding used when constructor credentials are folded into a DSN. +fn percent_decode_credential(raw: &str) -> String { + raw.replace("%3B", ";") + .replace("%3b", ";") + .replace("%25", "%") +} + +/// Parses one integer-like constructor flag stored in the internal DBLIB DSN. +fn dsn_bool(values: &HashMap, key: &str) -> bool { + values + .get(key) + .and_then(|value| value.parse::().ok()) + .is_some_and(|value| value != 0) +} + +/// Parses the semicolon-separated `dblib:` DSN accepted by PDO_DBLIB. +fn parse_dsn(dsn: &str) -> Result { + let body = dsn + .strip_prefix("dblib:") + .ok_or_else(|| "could not find driver".to_string())?; + let mut values = HashMap::new(); + for pair in body.split(';').filter(|pair| !pair.is_empty()) { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + values.insert(key.trim().to_ascii_lowercase(), value.trim().to_string()); + } + let timeout = values + .get("timeout") + .and_then(|value| value.parse::().ok()) + .unwrap_or(30); + let port = values.get("port").and_then(|value| value.parse::().ok()); + Ok(DsnOptions { + host: values + .remove("host") + .unwrap_or_else(|| "127.0.0.1".to_string()), + port, + dbname: values.remove("dbname"), + user: values + .remove("user") + .map(|value| percent_decode_credential(&value)), + password: values + .remove("password") + .map(|value| percent_decode_credential(&value)), + charset: values.remove("charset"), + appname: values + .remove("appname") + .unwrap_or_else(|| "PHP FreeTDS".to_string()), + version: values.remove("version"), + connection_timeout: values + .get("connection_timeout") + .and_then(|value| value.parse::().ok()) + .unwrap_or(timeout), + query_timeout: values + .get("query_timeout") + .and_then(|value| value.parse::().ok()) + .unwrap_or(timeout), + stringify_uniqueidentifier: dsn_bool(&values, "stringify_uniqueidentifier"), + skip_empty_rowsets: dsn_bool(&values, "skip_empty_rowsets"), + datetime_convert: dsn_bool(&values, "datetime_convert"), + }) +} + +/// Builds the server name passed to `dbopen`, using FreeTDS's documented port override syntax. +fn server_name(options: &DsnOptions) -> String { + match options.port { + Some(port) => format!("{}:{}", options.host, port), + None => options.host.clone(), + } +} + +/// Maps php-src's accepted PDO_DBLIB DSN version spellings to FreeTDS constants. +fn tds_login_version(value: &str) -> Option { + match value { + "4.2" => Some(3), + "4.6" => Some(1), + "5.0" | "6.0" | "7.0" => Some(4), + "7.1" => Some(5), + "7.2" | "8.0" => Some(6), + "7.3" => Some(7), + "7.4" => Some(8), + "10.0" => Some(2), + "auto" => Some(0), + _ => None, + } +} + +/// Sets one string-valued DB-Library login property when a value is present. +unsafe fn set_login_string( + login: *mut LoginRecord, + value: Option<&str>, + property: c_int, +) -> Result<(), String> { + let Some(value) = value else { + return Ok(()); + }; + let value = CString::new(value).map_err(|_| "PDO_DBLIB: DSN contains NUL".to_string())?; + if dbsetlname(login, value.as_ptr(), property) == FAIL { + Err("PDO_DBLIB: failed to set login property".to_string()) + } else { + Ok(()) + } +} + +/// Live FreeTDS DB-Library connection. +pub struct DblibConn { + link: *mut DbProcess, + error: Box, + pub changes: i64, + pub in_transaction: bool, + stringify_uniqueidentifier: bool, + skip_empty_rowsets: bool, + datetime_convert: bool, +} + +// DB-Library handles are used only while the bridge connection-table mutex is held. +unsafe impl Send for DblibConn {} + +impl Drop for DblibConn { + /// Cancels pending results and closes the native DBPROCESS. + fn drop(&mut self) { + unsafe { + if !self.link.is_null() { + dbcancel(self.link); + dbclose(self.link); + } + } + } +} + +impl DblibConn { + /// Opens a FreeTDS connection from a PDO `dblib:` DSN. + pub fn open(dsn: &str) -> Result { + initialize()?; + let options = parse_dsn(dsn)?; + if let Ok(mut error) = open_error().lock() { + *error = ErrorState::default(); + } + unsafe { + dbsetlogintime(options.connection_timeout.max(0)); + dbsettime(options.query_timeout.max(0)); + let login = dblogin(); + if login.is_null() { + return Err("PDO_DBLIB: dblogin() failed".to_string()); + } + let configured = set_login_string(login, options.user.as_deref(), DBSETUSER) + .and_then(|_| set_login_string(login, options.password.as_deref(), DBSETPWD)) + .and_then(|_| set_login_string(login, Some(&options.appname), DBSETAPP)) + .and_then(|_| set_login_string(login, options.charset.as_deref(), DBSETCHARSET)) + .and_then(|_| set_login_string(login, options.dbname.as_deref(), DBSETDBNAME)); + if let Some(version) = options.version.as_deref() { + let Some(version) = tds_login_version(version) else { + dbloginfree(login); + return Err("PDO_DBLIB: Invalid version specified in connection string.".to_string()); + }; + if configured.is_ok() && dbsetlversion(login, version) == FAIL { + dbloginfree(login); + return Err("PDO_DBLIB: Failed to set version specified in connection string.".to_string()); + } + } + if let Err(error) = configured { + dbloginfree(login); + return Err(error); + } + let host = CString::new(server_name(&options)) + .map_err(|_| "PDO_DBLIB: host contains NUL".to_string())?; + let link = dbopen(login, host.as_ptr()); + dbloginfree(login); + if link.is_null() { + let message = open_error() + .lock() + .ok() + .map(|error| error.message.clone()) + .filter(|message| !message.is_empty()) + .unwrap_or_else(|| "PDO_DBLIB: unable to connect".to_string()); + return Err(message); + } + let max_text = b"2147483647\0"; + let quoted_identifiers = b"1\0"; + dbsetopt(link, DBTEXTSIZE, max_text.as_ptr().cast::(), -1); + dbsetopt( + link, + DBQUOTEDIDENT, + quoted_identifiers.as_ptr().cast::(), + -1, + ); + let mut error = Box::new(ErrorState::default()); + dbsetuserdata(link, (&mut *error as *mut ErrorState).cast::()); + Ok(Self { + link, + error, + changes: 0, + in_transaction: false, + stringify_uniqueidentifier: options.stringify_uniqueidentifier, + skip_empty_rowsets: options.skip_empty_rowsets, + datetime_convert: options.datetime_convert, + }) + } + } + + /// Reports whether FreeTDS considers this connection dead. + pub fn is_alive(&self) -> bool { + unsafe { dbdead(self.link) == 0 } + } + + /// Clears the connection diagnostic before a new operation. + fn clear_error(&mut self) { + *self.error = ErrorState::default(); + unsafe { dbsetuserdata(self.link, (&mut *self.error as *mut ErrorState).cast::()) }; + } + + /// Returns the connection's current five-character SQLSTATE. + pub fn sqlstate(&self) -> &str { + if self.error.sqlstate.is_empty() { + "00000" + } else { + &self.error.sqlstate + } + } + + /// Returns the current DB-Library/server native error number. + pub fn errcode(&self) -> i64 { + self.error.native_code + } + + /// Returns the current DB-Library/server diagnostic text. + pub fn errmsg(&self) -> &str { + &self.error.message + } + + /// Returns the DB-Library operating-system error code for extended errorInfo. + pub fn os_errcode(&self) -> i64 { + self.error.os_code + } + + /// Returns the DB-Library error severity for extended errorInfo. + pub fn severity(&self) -> i64 { + self.error.severity + } + + /// Returns the DB-Library operating-system diagnostic text. + pub fn os_errmsg(&self) -> &str { + &self.error.os_message + } + + /// Records a bridge-generated error on the connection for PDO diagnostics. + pub fn set_error(&mut self, sqlstate: &str, native_code: i64, message: String) { + *self.error = ErrorState { + sqlstate: sqlstate.to_string(), + native_code, + message, + ..ErrorState::default() + }; + } + + /// Applies a writable PDO_DBLIB connection attribute. + pub fn set_attribute(&mut self, attribute: i64, value: i64) -> bool { + match attribute { + 2 | 1001 => unsafe { dbsettime(value as c_int) == SUCCEED }, + 1002 => { + self.stringify_uniqueidentifier = value != 0; + true + } + 1005 => { + self.skip_empty_rowsets = value != 0; + true + } + 1006 => { + self.datetime_convert = value != 0; + true + } + _ => false, + } + } + + /// Reads a boolean PDO_DBLIB attribute, or `None` for a non-readable one. + pub fn attribute_bool(&self, attribute: i64) -> Option { + match attribute { + 1002 => Some(self.stringify_uniqueidentifier), + 1005 => Some(self.skip_empty_rowsets), + 1006 => Some(self.datetime_convert), + _ => None, + } + } + + /// Executes SQL and materializes every DB-Library result set. + pub fn execute(&mut self, sql: &str) -> Result, String> { + self.clear_error(); + unsafe { dbcancel(self.link) }; + let sql = CString::new(sql).map_err(|_| "PDO_DBLIB: SQL contains NUL".to_string())?; + if unsafe { dbcmd(self.link, sql.as_ptr()) } == FAIL + || unsafe { dbsqlexec(self.link) } == FAIL + { + return Err(self.operation_error("PDO_DBLIB: query execution failed")); + } + let rowsets = unsafe { self.collect_rowsets()? }; + self.changes = rowsets.first().map_or(0, |rowset| rowset.row_count); + Ok(rowsets) + } + + /// Executes one transaction-control command and updates local PDO state. + pub fn transaction(&mut self, sql: &str, active_after: bool) -> bool { + match self.execute(sql) { + Ok(_) => { + self.in_transaction = active_after; + true + } + Err(_) => false, + } + } + + /// Returns the FreeTDS library version string. + pub fn client_version(&self) -> String { + unsafe { owned_cstr(dbversion()) } + } + + /// Returns the negotiated TDS protocol version. + pub fn tds_version(&self) -> &'static str { + match unsafe { dbtds(self.link) } { + 1 => "2.0", + 2 => "3.4", + 3 => "4.0", + 4 => "4.2", + 5 => "4.6", + 6 => "4.9.5", + 7 => "5.0", + 8 => "7.0", + 9 => "7.1", + 10 => "7.2", + 11 => "7.3", + 12 => "7.4", + _ => "", + } + } + + /// Builds a stable error string when the native callback supplied no text. + fn operation_error(&self, fallback: &str) -> String { + if self.error.message.is_empty() { + fallback.to_string() + } else { + self.error.message.clone() + } + } + + /// Drains all DB-Library results into bridge-owned rowsets. + unsafe fn collect_rowsets(&mut self) -> Result, String> { + let mut rowsets = Vec::new(); + let mut computed_column_count = 0usize; + loop { + match dbresults(self.link) { + NO_MORE_RESULTS => break, + FAIL => return Err(self.operation_error("PDO_DBLIB: dbresults() returned FAIL")), + SUCCEED => { + let column_count = dbnumcols(self.link).max(0) as usize; + let columns = (0..column_count) + .map(|index| { + read_column(self.link, index + 1, &mut computed_column_count) + }) + .collect(); + let mut rows = Vec::new(); + loop { + match dbnextrow(self.link) { + NO_MORE_ROWS => break, + FAIL => { + return Err(self.operation_error( + "PDO_DBLIB: dbnextrow() returned FAIL", + )) + } + _ => { + rows.push( + (0..column_count) + .map(|index| { + read_cell( + self.link, + index + 1, + self.stringify_uniqueidentifier, + self.datetime_convert, + ) + }) + .collect(), + ); + } + } + } + if !self.skip_empty_rowsets || column_count > 0 { + rowsets.push(DblibRowset { + columns, + rows, + row_count: i64::from(dbcount(self.link)), + }); + } + } + _ => return Err("PDO_DBLIB: unexpected dbresults() status".to_string()), + } + } + Ok(rowsets) + } +} + +/// One materialized result column. +#[derive(Clone)] +pub struct DblibColumn { + pub name: String, + pub native_type: c_int, + pub max_len: i64, + pub precision: i64, + pub scale: i64, + pub source: String, + pub user_type: i64, +} + +/// One materialized DB-Library cell in the bridge's common PDO value shape. +#[derive(Clone)] +pub enum DblibCell { + Null, + Int(i64), + Float(f64), + Bytes(Vec, bool), +} + +/// One materialized DB-Library rowset. +pub struct DblibRowset { + pub columns: Vec, + pub rows: Vec>, + pub row_count: i64, +} + +/// Reads metadata for one one-based DB-Library column. +unsafe fn read_column( + process: *mut DbProcess, + column: usize, + computed_column_count: &mut usize, +) -> DblibColumn { + let raw_name = dbcolname(process, column as c_int); + let name = if raw_name.is_null() || *raw_name == 0 { + let name = if *computed_column_count == 0 { + "computed".to_string() + } else { + format!("computed{}", *computed_column_count) + }; + *computed_column_count += 1; + name + } else { + owned_cstr(raw_name) + }; + let type_info = dbcoltypeinfo(process, column as c_int); + let (precision, scale) = if type_info.is_null() { + (0, 0) + } else { + (i64::from((*type_info).precision), i64::from((*type_info).scale)) + }; + DblibColumn { + name, + native_type: dbcoltype(process, column as c_int), + max_len: i64::from(dbcollen(process, column as c_int)), + precision, + scale, + source: owned_cstr(dbcolsource(process, column as c_int)), + user_type: i64::from(dbcolutype(process, column as c_int)), + } +} + +/// Reads and converts one one-based DB-Library cell. +unsafe fn read_cell( + process: *mut DbProcess, + column: usize, + stringify_uniqueidentifier: bool, + datetime_convert: bool, +) -> DblibCell { + let data = dbdata(process, column as c_int); + let len = dbdatlen(process, column as c_int); + if data.is_null() && len == 0 { + return DblibCell::Null; + } + let native_type = dbcoltype(process, column as c_int); + match native_type { + SYBINT1 | SYBBIT => DblibCell::Int(i64::from(ptr::read_unaligned(data))), + SYBINT2 => DblibCell::Int(i64::from(ptr::read_unaligned(data.cast::()))), + SYBINT4 => DblibCell::Int(i64::from(ptr::read_unaligned(data.cast::()))), + SYBINT8 => DblibCell::Int(ptr::read_unaligned(data.cast::())), + SYBREAL => DblibCell::Float(f64::from(ptr::read_unaligned(data.cast::()))), + SYBFLT8 => DblibCell::Float(ptr::read_unaligned(data.cast::())), + SYBDECIMAL | SYBNUMERIC | SYBMONEY | SYBMONEY4 | SYBMONEYN => { + let mut value = 0.0f64; + let converted = dbconvert( + ptr::null_mut(), + native_type, + data, + len, + SYBFLT8, + (&mut value as *mut f64).cast::(), + std::mem::size_of::() as c_int, + ); + if converted < 0 { + converted_text(process, native_type, data, len) + } else { + DblibCell::Float(value) + } + } + SYBUNIQUE if stringify_uniqueidentifier => { + converted_uniqueidentifier(native_type, data, len) + } + SYBBINARY | SYBVARBINARY | SYBIMAGE | SYBUNIQUE => { + DblibCell::Bytes(std::slice::from_raw_parts(data, len.max(0) as usize).to_vec(), true) + } + SYBCHAR | SYBVARCHAR | SYBTEXT | SYBNTEXT | SYBNVARCHAR => { + DblibCell::Bytes(std::slice::from_raw_parts(data, len.max(0) as usize).to_vec(), false) + } + SYBDATETIME | SYBDATETIME4 | SYBMSDATETIME2 if !datetime_convert => { + cracked_datetime(process, native_type, data) + } + SYBDATETIME | SYBDATETIME4 | SYBMSDATE | SYBMSTIME | SYBMSDATETIME2 + | SYBMSDATETIMEOFFSET | SYBINTN | SYBFLTN | SYBBITN => { + converted_text(process, native_type, data, len) + } + _ => converted_text(process, native_type, data, len), + } +} + +/// Converts a SQL Server uniqueidentifier to php-src's uppercase 36-byte text form. +unsafe fn converted_uniqueidentifier( + native_type: c_int, + data: *const c_uchar, + len: c_int, +) -> DblibCell { + let mut buffer = vec![0u8; 37]; + let converted = dbconvert( + ptr::null_mut(), + native_type, + data, + len, + SYBCHAR, + buffer.as_mut_ptr(), + 36, + ); + if converted <= 0 { + return DblibCell::Bytes(Vec::new(), false); + } + buffer.truncate(converted as usize); + buffer.make_ascii_uppercase(); + DblibCell::Bytes(buffer, false) +} + +/// Formats DBLIB datetime values using php-src's fixed second-resolution representation. +unsafe fn cracked_datetime( + process: *mut DbProcess, + native_type: c_int, + data: *const c_uchar, +) -> DblibCell { + let mut datetime = std::mem::MaybeUninit::::zeroed(); + if dbconvert( + process, + native_type, + data, + -1, + SYBDATETIME, + datetime.as_mut_ptr().cast::(), + -1, + ) <= 0 + { + return DblibCell::Bytes(Vec::new(), false); + } + let mut record = std::mem::MaybeUninit::::zeroed(); + if dbdatecrack(process, record.as_mut_ptr(), datetime.as_mut_ptr()) != SUCCEED { + return DblibCell::Bytes(Vec::new(), false); + } + let record = record.assume_init(); + DblibCell::Bytes( + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", + record.dateyear, + record.datemonth + 1, + record.datedmonth, + record.datehour, + record.dateminute, + record.datesecond + ) + .into_bytes(), + false, + ) +} + +/// Converts an arbitrary DB-Library value through FreeTDS's own SQLCHAR converter. +unsafe fn converted_text( + process: *mut DbProcess, + native_type: c_int, + data: *const c_uchar, + len: c_int, +) -> DblibCell { + let mut buffer = vec![0u8; (len.max(32) as usize).saturating_mul(2).saturating_add(64)]; + let converted = dbconvert( + process, + native_type, + data, + len, + SYBCHAR, + buffer.as_mut_ptr(), + buffer.len() as c_int, + ); + if converted <= 0 { + DblibCell::Bytes(Vec::new(), false) + } else { + buffer.truncate(converted as usize); + while buffer.last() == Some(&b' ') { + buffer.pop(); + } + DblibCell::Bytes(buffer, false) + } +} + +/// Bound value used by DBLIB's mandatory emulated-prepare path. +#[derive(Clone, Default)] +enum BindValue { + #[default] + Null, + Int(i64), + Float(f64), + Text(Vec, bool), + Blob(Vec), +} + +/// Live DBLIB statement with bridge-owned bindings and materialized rowsets. +pub struct DblibStmt { + pub conn_id: i64, + translated_sql: String, + named_map: HashMap, + order: Vec, + binds: Vec, + bound: Vec, + rowsets: Vec, + rowset_index: usize, + cursor: isize, + executed: bool, + pub sent_sql: String, + error: ErrorState, +} + +impl DblibStmt { + /// Creates an emulated DBLIB statement and records PDO placeholder ordering. + pub fn new(conn_id: i64, sql: &str) -> Result { + let (translated_sql, named_map, order, mixed) = + crate::my::translate_pdo_placeholders(sql); + if mixed { + return Err("Invalid parameter number: mixed named and positional parameters".to_string()); + } + let slots = order.iter().copied().max().unwrap_or(0).max(0) as usize; + Ok(Self { + conn_id, + translated_sql, + named_map, + order, + binds: vec![BindValue::Null; slots], + bound: vec![false; slots], + rowsets: Vec::new(), + rowset_index: 0, + cursor: -1, + executed: false, + sent_sql: String::new(), + error: ErrorState::default(), + }) + } + + /// Resolves a named PDO placeholder to its one-based bind slot. + pub fn parameter_index(&self, name: &str) -> i64 { + self.named_map + .get(name.trim_start_matches(':')) + .copied() + .unwrap_or(-1) + } + + /// Stores an integer bind in a one-based slot. + pub fn bind_int(&mut self, index: i64, value: i64) -> bool { + self.set_bind(index, BindValue::Int(value)) + } + + /// Stores a floating-point bind in a one-based slot. + pub fn bind_double(&mut self, index: i64, value: f64) -> bool { + self.set_bind(index, BindValue::Float(value)) + } + + /// Stores a text bind, optionally using DBLIB's national-string `N` prefix. + pub fn bind_text(&mut self, index: i64, value: Vec, national: bool) -> bool { + self.set_bind(index, BindValue::Text(value, national)) + } + + /// Stores a binary bind rendered as a T-SQL hexadecimal literal. + pub fn bind_blob(&mut self, index: i64, value: Vec) -> bool { + self.set_bind(index, BindValue::Blob(value)) + } + + /// Stores a SQL NULL bind in a one-based slot. + pub fn bind_null(&mut self, index: i64) -> bool { + self.set_bind(index, BindValue::Null) + } + + /// Updates one bind slot and records that the caller explicitly supplied it. + fn set_bind(&mut self, index: i64, value: BindValue) -> bool { + let Some(slot) = usize::try_from(index).ok().and_then(|index| index.checked_sub(1)) else { + return false; + }; + if slot >= self.binds.len() { + return false; + } + self.binds[slot] = value; + self.bound[slot] = true; + true + } + + /// Clears buffered execution state while retaining parameter bindings. + pub fn reset(&mut self) { + self.rowsets.clear(); + self.rowset_index = 0; + self.cursor = -1; + self.executed = false; + self.sent_sql.clear(); + self.error = ErrorState::default(); + } + + /// Clears every parameter binding and buffered execution state. + pub fn clear_bindings(&mut self) { + self.reset(); + self.bound.fill(false); + self.binds.fill(BindValue::Null); + } + + /// Renders the emulated SQL and executes it through the owning connection. + pub fn execute(&mut self, conn: &mut DblibConn) -> Result<(), String> { + if self.bound.iter().any(|bound| !bound) { + self.error.sqlstate = "HY093".to_string(); + self.error.message = + "Invalid parameter number: number of bound variables does not match number of tokens" + .to_string(); + return Err(self.error.message.clone()); + } + self.sent_sql = match interpolate(&self.translated_sql, &self.order, &self.binds) { + Ok(sql) => sql, + Err(message) => { + self.error.sqlstate = "HY093".to_string(); + self.error.message = message.clone(); + return Err(message); + } + }; + match conn.execute(&self.sent_sql) { + Ok(rowsets) => { + self.rowsets = rowsets; + self.rowset_index = 0; + self.cursor = -1; + self.executed = true; + self.error = ErrorState::default(); + Ok(()) + } + Err(message) => { + self.error = conn.error.as_ref().clone(); + self.error.message = message.clone(); + Err(message) + } + } + } + + /// Returns whether the statement has no materialized execution yet. + pub fn needs_execute(&self) -> bool { + !self.executed + } + + /// Advances to the next row of the current materialized rowset. + pub fn step(&mut self) -> i64 { + let Some(rowset) = self.rowsets.get(self.rowset_index) else { + return 0; + }; + let next = self.cursor + 1; + if next < rowset.rows.len() as isize { + self.cursor = next; + 1 + } else { + 0 + } + } + + /// Advances to the next result set, resetting its row cursor. + pub fn next_rowset(&mut self) -> bool { + if self.rowset_index + 1 >= self.rowsets.len() { + return false; + } + self.rowset_index += 1; + self.cursor = -1; + true + } + + /// Returns the row count reported for the active materialized rowset. + pub fn current_row_count(&self) -> i64 { + self.rowset().map_or(0, |rowset| rowset.row_count) + } + + /// Returns the active result set, if execution produced one. + fn rowset(&self) -> Option<&DblibRowset> { + self.rowsets.get(self.rowset_index) + } + + /// Returns the active row, if `step()` positioned the cursor on one. + fn row(&self) -> Option<&[DblibCell]> { + usize::try_from(self.cursor) + .ok() + .and_then(|index| self.rowset()?.rows.get(index)) + .map(Vec::as_slice) + } + + /// Returns the current row's cell at `index`. + pub fn cell(&self, index: usize) -> Option<&DblibCell> { + self.row()?.get(index) + } + + /// Returns the bridge's common PDO storage-class code for one current cell. + pub fn column_type(&self, index: i64) -> i64 { + let Ok(index) = usize::try_from(index) else { + return 5; + }; + match self.cell(index) { + Some(DblibCell::Int(_)) => 1, + Some(DblibCell::Float(_)) => 2, + Some(DblibCell::Bytes(_, true)) => 4, + Some(DblibCell::Bytes(_, false)) => 3, + Some(DblibCell::Null) | None => 5, + } + } + + /// Returns one current cell as an integer. + pub fn column_int(&self, index: i64) -> i64 { + let Ok(index) = usize::try_from(index) else { + return 0; + }; + match self.cell(index) { + Some(DblibCell::Int(value)) => *value, + Some(DblibCell::Float(value)) => *value as i64, + Some(DblibCell::Bytes(value, _)) => String::from_utf8_lossy(value).parse().unwrap_or(0), + Some(DblibCell::Null) | None => 0, + } + } + + /// Returns one current cell as a floating-point value. + pub fn column_double(&self, index: i64) -> f64 { + let Ok(index) = usize::try_from(index) else { + return 0.0; + }; + match self.cell(index) { + Some(DblibCell::Int(value)) => *value as f64, + Some(DblibCell::Float(value)) => *value, + Some(DblibCell::Bytes(value, _)) => String::from_utf8_lossy(value).parse().unwrap_or(0.0), + Some(DblibCell::Null) | None => 0.0, + } + } + + /// Returns one current cell as its PDO byte payload. + pub fn column_data(&self, index: i64) -> Vec { + let Ok(index) = usize::try_from(index) else { + return Vec::new(); + }; + match self.cell(index) { + Some(DblibCell::Int(value)) => value.to_string().into_bytes(), + Some(DblibCell::Float(value)) => value.to_string().into_bytes(), + Some(DblibCell::Bytes(value, _)) => value.clone(), + Some(DblibCell::Null) | None => Vec::new(), + } + } + + /// Returns one current result column's name. + pub fn column_name(&self, index: i64) -> String { + usize::try_from(index) + .ok() + .and_then(|index| self.column(index)) + .map(|column| column.name.clone()) + .unwrap_or_default() + } + + /// Returns one current result column's native FreeTDS type name. + pub fn column_native_type(&self, index: i64) -> String { + usize::try_from(index) + .ok() + .and_then(|index| self.column(index)) + .map(|column| native_type_name(column.native_type).to_string()) + .unwrap_or_default() + } + + /// Returns one current result column's declared maximum byte length. + pub fn column_len(&self, index: i64) -> i64 { + usize::try_from(index) + .ok() + .and_then(|index| self.column(index)) + .map_or(-1, |column| column.max_len) + } + + /// Returns one current result column's DB-Library precision. + pub fn column_precision(&self, index: i64) -> i64 { + usize::try_from(index) + .ok() + .and_then(|index| self.column(index)) + .map_or(0, |column| column.precision) + } + + /// Returns one current result column's DB-Library scale. + pub fn column_scale(&self, index: i64) -> i64 { + usize::try_from(index) + .ok() + .and_then(|index| self.column(index)) + .map_or(0, |column| column.scale) + } + + /// Returns one current result column's source expression/table label. + pub fn column_source(&self, index: i64) -> String { + usize::try_from(index) + .ok() + .and_then(|index| self.column(index)) + .map(|column| column.source.clone()) + .unwrap_or_default() + } + + /// Returns one current result column's DB-Library native type identifier. + pub fn column_native_type_id(&self, index: i64) -> i64 { + usize::try_from(index) + .ok() + .and_then(|index| self.column(index)) + .map_or(0, |column| i64::from(column.native_type)) + } + + /// Returns one current result column's server user-type identifier. + pub fn column_user_type_id(&self, index: i64) -> i64 { + usize::try_from(index) + .ok() + .and_then(|index| self.column(index)) + .map_or(0, |column| column.user_type) + } + + /// Returns the current result set's column at `index`. + pub fn column(&self, index: usize) -> Option<&DblibColumn> { + self.rowset()?.columns.get(index) + } + + /// Returns the current result set's column count. + pub fn column_count(&self) -> i64 { + self.rowset().map_or(0, |rowset| rowset.columns.len() as i64) + } + + /// Returns the statement SQLSTATE. + pub fn sqlstate(&self) -> &str { + if self.error.sqlstate.is_empty() { + "00000" + } else { + &self.error.sqlstate + } + } + + /// Returns the statement native error code. + pub fn errcode(&self) -> i64 { + self.error.native_code + } + + /// Returns the statement diagnostic text. + pub fn errmsg(&self) -> &str { + &self.error.message + } + + /// Returns the statement's DB-Library operating-system error code. + pub fn os_errcode(&self) -> i64 { + self.error.os_code + } + + /// Returns the statement's DB-Library error severity. + pub fn severity(&self) -> i64 { + self.error.severity + } + + /// Returns the statement's DB-Library operating-system diagnostic text. + pub fn os_errmsg(&self) -> &str { + &self.error.os_message + } +} + +/// Interpolates one translated DBLIB statement with safely quoted T-SQL values. +fn interpolate(sql: &str, order: &[i64], binds: &[BindValue]) -> Result { + let mut output = String::with_capacity(sql.len() + binds.len() * 8); + let mut marker = 0usize; + let mut chars = sql.chars().peekable(); + let mut quote = None; + while let Some(ch) = chars.next() { + if let Some(active) = quote { + output.push(ch); + if ch == active { + if chars.peek() == Some(&active) { + output.push(chars.next().unwrap_or(active)); + } else { + quote = None; + } + } + continue; + } + if ch == '\'' || ch == '"' || ch == '[' { + quote = Some(if ch == '[' { ']' } else { ch }); + output.push(ch); + continue; + } + if ch != '?' { + output.push(ch); + continue; + } + let slot = order + .get(marker) + .copied() + .and_then(|slot| usize::try_from(slot).ok()) + .and_then(|slot| slot.checked_sub(1)) + .ok_or_else(|| "Invalid parameter number".to_string())?; + render_bind(&mut output, binds.get(slot).ok_or_else(|| "Invalid parameter number".to_string())?); + marker += 1; + } + if marker != order.len() { + return Err("Invalid parameter number".to_string()); + } + Ok(output) +} + +/// Appends one bound value as a T-SQL literal. +fn render_bind(output: &mut String, value: &BindValue) { + match value { + BindValue::Null => output.push_str("NULL"), + BindValue::Int(value) => output.push_str(&value.to_string()), + BindValue::Float(value) if value.is_finite() => output.push_str(&value.to_string()), + BindValue::Float(_) => output.push_str("NULL"), + BindValue::Text(bytes, national) => { + if *national { + output.push('N'); + } + output.push('\''); + for ch in String::from_utf8_lossy(bytes).chars() { + if ch == '\'' { + output.push('\''); + } + output.push(ch); + } + output.push('\''); + } + BindValue::Blob(bytes) => { + output.push_str("0x"); + for byte in bytes { + use std::fmt::Write; + let _ = write!(output, "{byte:02X}"); + } + } + } +} + +/// Maps a FreeTDS native type ID to php-src's PDO_DBLIB metadata spelling. +pub fn native_type_name(native_type: c_int) -> &'static str { + match native_type { + 31 => "nvarchar", + 34 => "image", + 35 => "text", + 36 => "uniqueidentifier", + 37 => "varbinary", + 38 | 127 => "bigint", + 39 | 167 => "varchar", + 40 => "date", + 41 => "time", + 42 => "datetime2", + 43 => "datetimeoffset", + 45 | 173 => "binary", + 47 | 175 => "char", + 48 => "tinyint", + 50 | 104 => "bit", + 52 => "smallint", + 55 | 106 => "decimal", + 56 => "int", + 58 => "smalldatetime", + 59 => "real", + 60 => "money", + 61 => "datetime", + 62 => "float", + 63 | 108 => "numeric", + 98 => "sql_variant", + 99 => "ntext", + 122 => "smallmoney", + 165 => "varbinary", + 189 => "timestamp", + 231 => "nvarchar", + 239 => "nchar", + 240 => "geometry", + 241 => "xml", + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Parses the PDO_DBLIB DSN defaults and explicit client options. + #[test] + fn parses_dblib_dsn() { + let options = parse_dsn( + "dblib:host=db;port=1433;dbname=app;user=user%3Bname;password=p%25w;charset=UTF-8;version=7.4;timeout=4;stringify_uniqueidentifier=1;skip_empty_rowsets=1;datetime_convert=1", + ) + .unwrap(); + assert_eq!(options.host, "db"); + assert_eq!(options.port, Some(1433)); + assert_eq!(options.dbname.as_deref(), Some("app")); + assert_eq!(options.user.as_deref(), Some("user;name")); + assert_eq!(options.password.as_deref(), Some("p%w")); + assert_eq!(options.version.as_deref(), Some("7.4")); + assert_eq!(options.connection_timeout, 4); + assert_eq!(options.query_timeout, 4); + assert!(options.stringify_uniqueidentifier); + assert!(options.skip_empty_rowsets); + assert!(options.datetime_convert); + assert_eq!(tds_login_version("7.4"), Some(8)); + assert_eq!(tds_login_version("unsupported"), None); + assert_eq!(server_name(&options), "db:1433"); + } + + /// Leaves FreeTDS aliases untouched when no explicit PDO port extension is present. + #[test] + fn preserves_dblib_server_alias_without_port() { + let options = parse_dsn("dblib:host=production-alias").unwrap(); + assert_eq!(server_name(&options), "production-alias"); + } + + /// Interpolates reused named placeholders with SQL-safe DBLIB literals. + #[test] + fn interpolates_dblib_bindings() { + let (sql, _, order, _) = + crate::my::translate_pdo_placeholders("SELECT :name, :name, ?"); + let rendered = interpolate( + &sql, + &order, + &[ + BindValue::Text(b"O'Brien".to_vec(), true), + BindValue::Blob(vec![0, 255]), + ], + ) + .unwrap(); + assert_eq!(rendered, "SELECT N'O''Brien', N'O''Brien', 0x00FF"); + } + + /// Rejects mixed placeholder styles before DB-Library sees the statement. + #[test] + fn rejects_mixed_placeholder_styles() { + let result = DblibStmt::new(1, "SELECT ? AS positional, :named AS named"); + assert!(matches!(result, Err(message) if message.contains("mixed named and positional"))); + } + + /// Mirrors php-src's native PDO_DBLIB type metadata names. + #[test] + fn maps_native_type_names() { + assert_eq!(native_type_name(56), "int"); + assert_eq!(native_type_name(231), "nvarchar"); + assert_eq!(native_type_name(36), "uniqueidentifier"); + } + + /// Mirrors php-src's four DB-Library client-error SQLSTATE classifications. + #[test] + fn maps_dblib_client_error_sqlstates() { + assert_eq!(sqlstate_for_db_error(20017), "01002"); + assert_eq!(sqlstate_for_db_error(20002), "01002"); + assert_eq!(sqlstate_for_db_error(20010), "HY001"); + assert_eq!(sqlstate_for_db_error(20014), "28000"); + assert_eq!(sqlstate_for_db_error(20018), "HY000"); + } + + /// Runs a direct FreeTDS round-trip when an explicit live DSN is provided. + #[test] + #[ignore] + fn live_round_trip() { + let dsn = std::env::var("ELEPHC_DBLIB_DSN") + .expect("ELEPHC_DBLIB_DSN is required for the ignored live test"); + let mut connection = DblibConn::open(&dsn).unwrap_or_else(|error| { + panic!("PDO_DBLIB connection failed: {error}") + }); + let sets = connection + .execute("SELECT CAST(7 AS INT) AS n, CAST('Ada' AS VARCHAR(10)) AS name") + .unwrap_or_else(|error| panic!("PDO_DBLIB query failed: {error}")); + assert_eq!(sets.len(), 1); + assert_eq!(sets[0].columns[0].name, "n"); + assert_eq!(sets[0].columns[0].native_type, SYBINT4); + assert_eq!(sets[0].columns[0].max_len, 4); + assert!(matches!(sets[0].rows[0][0], DblibCell::Int(7))); + assert!(matches!(&sets[0].rows[0][1], DblibCell::Bytes(value, false) if value == b"Ada")); + + assert!(connection.set_attribute(1002, 1)); + let typed = connection + .execute("SELECT CAST('00112233-4455-6677-8899-AABBCCDDEEFF' AS uniqueidentifier), CAST('2024-02-03T04:05:06' AS datetime2)") + .unwrap_or_else(|error| panic!("PDO_DBLIB typed query failed: {error}")); + assert!(matches!(&typed[0].rows[0][0], DblibCell::Bytes(value, false) if value == b"00112233-4455-6677-8899-AABBCCDDEEFF")); + assert!(matches!(&typed[0].rows[0][1], DblibCell::Bytes(value, false) if value == b"2024-02-03 04:05:06")); + } + + /// Opens the live DBLIB fixture through the public C ABI used by compiled PHP. + #[test] + #[ignore] + fn live_c_abi_open() { + let dsn = CString::new( + std::env::var("ELEPHC_DBLIB_DSN") + .expect("ELEPHC_DBLIB_DSN is required for the ignored live test"), + ) + .unwrap(); + let empty = CString::new("").unwrap(); + let connection = unsafe { + crate::elephc_pdo_open_persistent( + dsn.as_ptr(), + 0, + 0, + empty.as_ptr(), + empty.as_ptr(), + 0, + empty.as_ptr(), + empty.as_ptr(), + ) + }; + assert!(connection > 0, "C ABI DBLIB open returned {connection}"); + assert_eq!( + unsafe { CStr::from_ptr(crate::elephc_pdo_driver_name(connection)) } + .to_string_lossy(), + "dblib" + ); + crate::elephc_pdo_close(connection); + } +} diff --git a/crates/elephc-pdo/src/driver.rs b/crates/elephc-pdo/src/driver.rs new file mode 100644 index 0000000000..f5148c4015 --- /dev/null +++ b/crates/elephc-pdo/src/driver.rs @@ -0,0 +1,172 @@ +//! Purpose: +//! Central registry for PDO drivers compiled into the elephc database bridge. +//! +//! Called from: +//! - `crate` connection dispatch, driver-name attributes, and availability exports. +//! +//! Key details: +//! - Registry order is PHP-visible through `pdo_drivers()` and remains stable. +//! - New optional drivers must add one variant and one `AVAILABLE` entry here. + +/// Identifies a PDO backend compiled into this bridge. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DriverKind { + #[cfg(feature = "cubrid")] + Cubrid, + #[cfg(feature = "dblib")] + Dblib, + #[cfg(feature = "firebird")] + Firebird, + #[cfg(feature = "informix")] + Informix, + #[cfg(feature = "ibm")] + Ibm, + #[cfg(feature = "odbc")] + Odbc, + #[cfg(feature = "sqlsrv")] + Sqlsrv, + #[cfg(feature = "oci")] + Oci, + Mysql, + Pgsql, + Sqlite, +} + +/// Drivers exposed to PHP, in the stable order used by the existing bridge. +pub(crate) const AVAILABLE: &[DriverKind] = &[ + #[cfg(feature = "cubrid")] + DriverKind::Cubrid, + #[cfg(feature = "dblib")] + DriverKind::Dblib, + #[cfg(feature = "firebird")] + DriverKind::Firebird, + #[cfg(feature = "informix")] + DriverKind::Informix, + #[cfg(feature = "ibm")] + DriverKind::Ibm, + #[cfg(feature = "odbc")] + DriverKind::Odbc, + #[cfg(feature = "sqlsrv")] + DriverKind::Sqlsrv, + #[cfg(feature = "oci")] + DriverKind::Oci, + DriverKind::Mysql, + DriverKind::Pgsql, + DriverKind::Sqlite, +]; + +impl DriverKind { + /// Returns the lowercase PDO driver name exposed by PHP. + pub(crate) const fn name(self) -> &'static str { + match self { + #[cfg(feature = "cubrid")] + Self::Cubrid => "cubrid", + #[cfg(feature = "dblib")] + Self::Dblib => "dblib", + #[cfg(feature = "firebird")] + Self::Firebird => "firebird", + #[cfg(feature = "informix")] + Self::Informix => "informix", + #[cfg(feature = "ibm")] + Self::Ibm => "ibm", + #[cfg(feature = "odbc")] + Self::Odbc => "odbc", + #[cfg(feature = "sqlsrv")] + Self::Sqlsrv => "sqlsrv", + #[cfg(feature = "oci")] + Self::Oci => "oci", + Self::Mysql => "mysql", + Self::Pgsql => "pgsql", + Self::Sqlite => "sqlite", + } + } + + /// Returns the DSN prefix, including its separating colon. + pub(crate) const fn dsn_prefix(self) -> &'static str { + match self { + #[cfg(feature = "cubrid")] + Self::Cubrid => "cubrid:", + #[cfg(feature = "dblib")] + Self::Dblib => "dblib:", + #[cfg(feature = "firebird")] + Self::Firebird => "firebird:", + #[cfg(feature = "informix")] + Self::Informix => "informix:", + #[cfg(feature = "ibm")] + Self::Ibm => "ibm:", + #[cfg(feature = "odbc")] + Self::Odbc => "odbc:", + #[cfg(feature = "sqlsrv")] + Self::Sqlsrv => "sqlsrv:", + #[cfg(feature = "oci")] + Self::Oci => "oci:", + Self::Mysql => "mysql:", + Self::Pgsql => "pgsql:", + Self::Sqlite => "sqlite:", + } + } + + /// Selects a compiled driver from a full colon-bearing DSN. + pub(crate) fn from_dsn(dsn: &str) -> Option { + AVAILABLE + .iter() + .copied() + .find(|driver| dsn.starts_with(driver.dsn_prefix())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Keeps the PHP-visible availability order stable. + #[test] + fn available_driver_order_is_stable() { + let names: Vec<_> = AVAILABLE.iter().map(|driver| driver.name()).collect(); + let mut expected = Vec::new(); + #[cfg(feature = "cubrid")] + expected.push("cubrid"); + #[cfg(feature = "dblib")] + expected.push("dblib"); + #[cfg(feature = "firebird")] + expected.push("firebird"); + #[cfg(feature = "informix")] + expected.push("informix"); + #[cfg(feature = "ibm")] + expected.push("ibm"); + #[cfg(feature = "odbc")] + expected.push("odbc"); + #[cfg(feature = "sqlsrv")] + expected.push("sqlsrv"); + #[cfg(feature = "oci")] + expected.push("oci"); + expected.extend(["mysql", "pgsql", "sqlite"]); + assert_eq!(names, expected); + } + + /// Dispatches only exact lowercase PDO prefixes followed by a colon. + #[test] + fn dsn_dispatch_requires_exact_registered_prefix() { + assert_eq!(DriverKind::from_dsn("sqlite::memory:"), Some(DriverKind::Sqlite)); + assert_eq!(DriverKind::from_dsn("pgsql:host=localhost"), Some(DriverKind::Pgsql)); + assert_eq!(DriverKind::from_dsn("mysql:host=localhost"), Some(DriverKind::Mysql)); + #[cfg(feature = "cubrid")] + assert_eq!(DriverKind::from_dsn("cubrid:dbname=demodb"), Some(DriverKind::Cubrid)); + #[cfg(feature = "dblib")] + assert_eq!(DriverKind::from_dsn("dblib:host=localhost"), Some(DriverKind::Dblib)); + #[cfg(feature = "firebird")] + assert_eq!(DriverKind::from_dsn("firebird:dbname=test.fdb"), Some(DriverKind::Firebird)); + #[cfg(feature = "informix")] + assert_eq!(DriverKind::from_dsn("informix:inventory"), Some(DriverKind::Informix)); + #[cfg(feature = "ibm")] + assert_eq!(DriverKind::from_dsn("ibm:SAMPLE"), Some(DriverKind::Ibm)); + #[cfg(feature = "odbc")] + assert_eq!(DriverKind::from_dsn("odbc:example"), Some(DriverKind::Odbc)); + #[cfg(feature = "sqlsrv")] + assert_eq!(DriverKind::from_dsn("sqlsrv:Server=localhost"), Some(DriverKind::Sqlsrv)); + #[cfg(feature = "oci")] + assert_eq!(DriverKind::from_dsn("oci:dbname=example"), Some(DriverKind::Oci)); + assert_eq!(DriverKind::from_dsn("SQLite::memory:"), None); + assert_eq!(DriverKind::from_dsn("sqlite"), None); + } +} diff --git a/crates/elephc-pdo/src/firebird.rs b/crates/elephc-pdo/src/firebird.rs new file mode 100644 index 0000000000..c8cff862c3 --- /dev/null +++ b/crates/elephc-pdo/src/firebird.rs @@ -0,0 +1,1218 @@ +//! Purpose: +//! Pure-Rust Firebird backend matching php-src's `pdo_firebird` surface. +//! +//! Called from: +//! - The bridge root when built with the optional `firebird` feature. +//! +//! Key details: +//! - Uses Firebird's wire protocol through `rsfbclient` on every supported target. +//! - Preserves PDO positional/named binding, scalar shapes, transaction controls, +//! date formatting attributes, and driver-native diagnostics. + +use std::collections::HashMap; +use std::str::FromStr; + +use rsfbclient::prelude::{transaction_builder, Execute, Queryable, TrRecordVersion}; +use rsfbclient::{builder_pure_rust, Dialect, FbError, Row, SimpleConnection, SqlType}; + +const ATTR_DATE_FORMAT: i64 = 1000; +const ATTR_TIME_FORMAT: i64 = 1001; +const ATTR_TIMESTAMP_FORMAT: i64 = 1002; +const TRANSACTION_ISOLATION_LEVEL: i64 = 1003; +const READ_COMMITTED: i64 = 1004; +const REPEATABLE_READ: i64 = 1005; +const SERIALIZABLE: i64 = 1006; +const WRITABLE_TRANSACTION: i64 = 1007; + +/// Parsed Firebird DSN fields with php-src-compatible defaults. +struct DsnOptions { + host: String, + port: u16, + dbname: String, + user: String, + password: String, + charset: String, + role: Option, + dialect: Dialect, + date_format: String, + time_format: String, + timestamp_format: String, + isolation: i64, + writable: bool, +} + +/// Decodes constructor credentials folded into the bridge DSN. +fn percent_decode_credential(raw: &str) -> String { + raw.replace("%3B", ";") + .replace("%3b", ";") + .replace("%25", "%") +} + +/// Splits php-src's Firebird `dbname` connection string into wire host, port, +/// and server-side database path/alias. +fn split_remote_dbname(dbname: String) -> (String, u16, String) { + for prefix in ["inet://", "inet4://", "inet6://"] { + if let Some(rest) = dbname.strip_prefix(prefix) { + let Some((authority, path)) = rest.split_once('/') else { + return ("localhost".to_string(), 3050, rest.to_string()); + }; + let (host, port) = split_host_port(authority, ':'); + let path = if path.starts_with('/') { + path.to_string() + } else { + path.to_string() + }; + return (host, port, path); + } + } + + let separator = if dbname.starts_with('[') { + dbname.find(']').and_then(|end| { + dbname[end + 1..] + .find(':') + .map(|offset| end + 1 + offset) + }) + } else { + dbname.find(':').filter(|index| *index != 1) + }; + let Some(separator) = separator else { + return ("localhost".to_string(), 3050, dbname); + }; + let authority = &dbname[..separator]; + let path = dbname[separator + 1..].to_string(); + let (host, port) = split_host_port(authority, '/'); + (host, port, path) +} + +/// Parses a host plus optional numeric port from one Firebird authority. +fn split_host_port(authority: &str, separator: char) -> (String, u16) { + let unbracketed = authority + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(authority); + let split = if authority.starts_with('[') && separator == ':' { + authority.rfind("]:").map(|index| (&authority[..=index], &authority[index + 2..])) + } else { + authority.rsplit_once(separator) + }; + if let Some((host, port)) = split { + if let Ok(port) = port.parse::() { + return (host.trim_matches(['[', ']']).to_string(), port); + } + } + (unbracketed.to_string(), 3050) +} + +/// Parses the semicolon-separated `firebird:` DSN and internal constructor options. +fn parse_dsn(dsn: &str) -> Result { + let body = dsn + .strip_prefix("firebird:") + .ok_or_else(|| "could not find driver".to_string())?; + let mut values = HashMap::new(); + for pair in body.split(';').filter(|pair| !pair.is_empty()) { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + values.insert(key.trim().to_ascii_lowercase(), value.trim().to_string()); + } + let dbname = values + .remove("dbname") + .filter(|value| !value.is_empty()) + .ok_or_else(|| "PDO_FIREBIRD: DSN requires dbname".to_string())?; + let dialect = Dialect::from_str(values.get("dialect").map_or("3", String::as_str)) + .map_err(|error| error.to_string())?; + let isolation = values + .get("transaction_isolation") + .and_then(|value| value.parse::().ok()) + .unwrap_or(REPEATABLE_READ); + if !matches!(isolation, READ_COMMITTED | REPEATABLE_READ | SERIALIZABLE) { + return Err("Pdo\\Firebird::TRANSACTION_ISOLATION_LEVEL must be a valid transaction isolation level (Pdo\\Firebird::READ_COMMITTED, Pdo\\Firebird::REPEATABLE_READ, or Pdo\\Firebird::SERIALIZABLE)".to_string()); + } + let (dsn_host, dsn_port, dbname) = split_remote_dbname(dbname); + Ok(DsnOptions { + host: values.remove("host").unwrap_or(dsn_host), + port: values.remove("port").and_then(|value| value.parse().ok()).unwrap_or(dsn_port), + dbname, + user: percent_decode_credential( + &values.remove("user").unwrap_or_else(|| "SYSDBA".to_string()), + ), + password: percent_decode_credential( + &values + .remove("password") + .unwrap_or_else(|| "masterkey".to_string()), + ), + charset: values.remove("charset").unwrap_or_else(|| "UTF-8".to_string()), + role: values.remove("role").filter(|value| !value.is_empty()), + dialect, + date_format: values + .remove("date_format") + .unwrap_or_else(|| "%Y-%m-%d".to_string()), + time_format: values + .remove("time_format") + .unwrap_or_else(|| "%H:%M:%S".to_string()), + timestamp_format: values + .remove("timestamp_format") + .unwrap_or_else(|| "%Y-%m-%d %H:%M:%S".to_string()), + isolation, + writable: values + .get("writable_transaction") + .and_then(|value| value.parse::().ok()) + .map_or(true, |value| value != 0), + }) +} + +/// One PDO-visible diagnostic produced by Firebird or the bridge. +#[derive(Clone)] +struct ErrorState { + sqlstate: String, + native_code: i64, + message: String, +} + +impl Default for ErrorState { + /// Creates the PDO no-error state. + fn default() -> Self { + Self { + sqlstate: "00000".to_string(), + native_code: 0, + message: String::new(), + } + } +} + +/// Maps a Firebird SQLCODE into the closest SQLSTATE emitted by php-src. +fn sqlstate_for_code(code: i32) -> &'static str { + match code { + -803 | -530 | -625 => "23000", + -204 => "42S02", + -206 => "42S22", + -104 => "42000", + -902 | -923 | -924 => "08006", + _ => "HY000", + } +} + +/// Converts an `rsfbclient` error into PDO diagnostic state. +fn error_state(error: FbError) -> ErrorState { + match error { + FbError::Sql { msg, code } => ErrorState { + sqlstate: sqlstate_for_code(code).to_string(), + native_code: i64::from(code), + message: msg, + }, + other => ErrorState { + sqlstate: "HY000".to_string(), + native_code: 0, + message: other.to_string(), + }, + } +} + +/// Live Firebird connection and its PDO-visible configuration. +pub struct FirebirdConn { + connection: SimpleConnection, + error: ErrorState, + pub changes: i64, + pub in_transaction: bool, + auto_commit: bool, + fetch_table_names: bool, + date_format: String, + time_format: String, + timestamp_format: String, + isolation: i64, + writable: bool, +} + +// The bridge serializes all uses under its connection-table mutex. +unsafe impl Send for FirebirdConn {} + +impl FirebirdConn { + /// Opens a remote Firebird attachment from a PDO DSN. + pub fn open(dsn: &str) -> Result { + let options = parse_dsn(dsn)?; + let charset = rsfbclient::Charset::from_str(&options.charset) + .map_err(|error| error.to_string())?; + let mut builder = builder_pure_rust(); + builder + .host(options.host) + .port(options.port) + .db_name(options.dbname) + .user(options.user) + .pass(options.password) + .charset(charset) + .dialect(options.dialect); + if let Some(role) = options.role { + builder.role(role); + } + let connection = builder.connect().map_err(|error| error.to_string())?.into(); + Ok(Self { + connection, + error: ErrorState::default(), + changes: 0, + in_transaction: false, + auto_commit: true, + fetch_table_names: false, + date_format: options.date_format, + time_format: options.time_format, + timestamp_format: options.timestamp_format, + isolation: options.isolation, + writable: options.writable, + }) + } + + /// Clears the diagnostic before a new native operation. + fn clear_error(&mut self) { + self.error = ErrorState::default(); + } + + /// Records one client error and returns its text to the caller. + fn record_error(&mut self, error: FbError) -> String { + self.error = error_state(error); + self.error.message.clone() + } + + /// Reports whether a lightweight system-table query succeeds. + pub fn is_alive(&mut self) -> bool { + self.connection + .query_first::<_, (i32,)>("SELECT 1 FROM RDB$DATABASE", ()) + .is_ok() + } + + /// Executes SQL and returns one materialized result set. + pub fn execute(&mut self, sql: &str, params: Vec) -> Result { + self.clear_error(); + let keyword = leading_keyword(sql); + if keyword == "SELECT" || keyword == "WITH" { + match self.connection.query::<_, Row>(sql, params) { + Ok(rows) => { + let result = FirebirdResult::from_rows(rows, self, sql); + self.changes = result.rows.len() as i64; + Ok(result) + } + Err(error) => Err(self.record_error(error)), + } + } else if has_sql_keyword(sql, "RETURNING") { + match self.connection.execute_returnable::<_, Row>(sql, params) { + Ok(row) => { + let result = FirebirdResult::from_rows(vec![row], self, sql); + self.changes = 1; + Ok(result) + } + Err(error) => Err(self.record_error(error)), + } + } else { + match self.connection.execute(sql, params) { + Ok(changes) => { + self.changes = changes as i64; + Ok(FirebirdResult::empty(changes as i64)) + } + Err(error) => Err(self.record_error(error)), + } + } + } + + /// Starts a manual transaction with the configured PDO_FIREBIRD mode. + pub fn begin(&mut self) -> bool { + if self.in_transaction { + return false; + } + let mut builder = transaction_builder(); + match self.isolation { + READ_COMMITTED => { + builder.with_read_commited(TrRecordVersion::NoRecordVersion); + } + REPEATABLE_READ => { + builder.with_concurrency(); + } + SERIALIZABLE => { + builder.with_consistency(); + } + _ => return false, + } + if self.writable { + builder.read_write(); + } else { + builder.read_only(); + } + match self.connection.begin_transaction_config(builder.build()) { + Ok(()) => { + self.in_transaction = true; + true + } + Err(error) => { + self.record_error(error); + false + } + } + } + + /// Commits the active manual transaction. + pub fn commit(&mut self) -> bool { + match self.connection.commit() { + Ok(()) => { + self.in_transaction = false; + true + } + Err(error) => { + self.record_error(error); + false + } + } + } + + /// Rolls back the active manual transaction. + pub fn rollback(&mut self) -> bool { + match self.connection.rollback() { + Ok(()) => { + self.in_transaction = false; + true + } + Err(error) => { + self.record_error(error); + false + } + } + } + + /// Returns the current SQLSTATE. + pub fn sqlstate(&self) -> &str { + &self.error.sqlstate + } + + /// Returns the current Firebird SQLCODE. + pub fn errcode(&self) -> i64 { + self.error.native_code + } + + /// Returns the current driver diagnostic text. + pub fn errmsg(&self) -> &str { + &self.error.message + } + + /// Stores a bridge-generated PDO error. + pub fn set_error(&mut self, sqlstate: &str, message: String) { + self.error = ErrorState { + sqlstate: sqlstate.to_string(), + native_code: 0, + message, + }; + } + + /// Returns the connected server's version string. + pub fn server_version(&mut self) -> String { + self.connection + .query_first::<_, (String,)>( + "SELECT RDB$GET_CONTEXT('SYSTEM', 'ENGINE_VERSION') FROM RDB$DATABASE", + (), + ) + .ok() + .flatten() + .map(|row| row.0) + .unwrap_or_default() + } + + /// Returns the pure-Rust Firebird client identity. + pub fn client_version(&self) -> String { + "rsfbclient-rust 0.27".to_string() + } + + /// Returns a stable connection-status string. + pub fn connection_status(&mut self) -> String { + if self.is_alive() { + "1".to_string() + } else { + "0".to_string() + } + } + + /// Reads a driver-specific integer/boolean attribute. + pub fn attribute_int(&self, attribute: i64) -> Option { + match attribute { + 0 => Some(self.auto_commit as i64), + 14 => Some(self.fetch_table_names as i64), + TRANSACTION_ISOLATION_LEVEL => Some(self.isolation), + WRITABLE_TRANSACTION => Some(self.writable as i64), + _ => None, + } + } + + /// Reads a driver-specific format-string attribute. + pub fn attribute_text(&self, attribute: i64) -> Option<&str> { + match attribute { + ATTR_DATE_FORMAT => Some(&self.date_format), + ATTR_TIME_FORMAT => Some(&self.time_format), + ATTR_TIMESTAMP_FORMAT => Some(&self.timestamp_format), + _ => None, + } + } + + /// Updates an integer/boolean PDO_FIREBIRD attribute outside a transaction. + pub fn set_attribute_int(&mut self, attribute: i64, value: i64) -> bool { + if self.in_transaction && matches!(attribute, 0 | TRANSACTION_ISOLATION_LEVEL | WRITABLE_TRANSACTION) { + self.set_error("HY000", "Cannot change transaction settings while a transaction is already open".to_string()); + return false; + } + match attribute { + 0 => { + self.auto_commit = value != 0; + true + } + 14 => { + self.fetch_table_names = value != 0; + true + } + TRANSACTION_ISOLATION_LEVEL + if matches!(value, READ_COMMITTED | REPEATABLE_READ | SERIALIZABLE) => + { + self.isolation = value; + true + } + WRITABLE_TRANSACTION => { + self.writable = value != 0; + true + } + _ => false, + } + } + + /// Updates a PDO_FIREBIRD date/time formatting string. + pub fn set_attribute_text(&mut self, attribute: i64, value: String) -> bool { + match attribute { + ATTR_DATE_FORMAT => self.date_format = value, + ATTR_TIME_FORMAT => self.time_format = value, + ATTR_TIMESTAMP_FORMAT => self.timestamp_format = value, + _ => return false, + } + true + } +} + +/// Returns the first executable SQL keyword while skipping leading whitespace/comments. +fn leading_keyword(sql: &str) -> String { + sql_keywords(sql).into_iter().next().unwrap_or_default() +} + +/// Reports whether executable SQL contains one standalone keyword outside +/// strings, quoted identifiers, and comments. +fn has_sql_keyword(sql: &str, expected: &str) -> bool { + sql_keywords(sql).iter().any(|keyword| keyword == expected) +} + +/// Tokenizes executable ASCII SQL words while ignoring quoted/commented text. +fn sql_keywords(sql: &str) -> Vec { + let bytes = sql.as_bytes(); + let mut words = Vec::new(); + let mut index = 0; + while index < bytes.len() { + if bytes[index].is_ascii_whitespace() { + index += 1; + continue; + } + if bytes[index] == b'-' && bytes.get(index + 1) == Some(&b'-') { + index += 2; + while index < bytes.len() && !matches!(bytes[index], b'\n' | b'\r') { + index += 1; + } + continue; + } + if bytes[index] == b'/' && bytes.get(index + 1) == Some(&b'*') { + index += 2; + while index + 1 < bytes.len() + && !(bytes[index] == b'*' && bytes[index + 1] == b'/') + { + index += 1; + } + index = (index + 2).min(bytes.len()); + continue; + } + if matches!(bytes[index], b'\'' | b'"') { + let quote = bytes[index]; + index += 1; + while index < bytes.len() { + if bytes[index] == quote { + if bytes.get(index + 1) == Some("e) { + index += 2; + continue; + } + index += 1; + break; + } + index += 1; + } + continue; + } + if bytes[index].is_ascii_alphabetic() || bytes[index] == b'_' { + let start = index; + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() + || matches!(bytes[index], b'_' | b'$')) + { + index += 1; + } + words.push(sql[start..index].to_ascii_uppercase()); + continue; + } + index += 1; + } + words +} + +/// One materialized Firebird result column. +pub struct FirebirdColumn { + pub name: String, + pub raw_type: i64, +} + +/// One materialized PDO scalar. +#[derive(Clone)] +pub enum FirebirdCell { + Null, + Int(i64), + Float(f64), + Bytes(Vec, bool), +} + +/// One materialized Firebird result set. +pub struct FirebirdResult { + columns: Vec, + rows: Vec>, +} + +impl FirebirdResult { + /// Creates an empty DML/DDL result with an affected-row count. + fn empty(_row_count: i64) -> Self { + Self { + columns: Vec::new(), + rows: Vec::new(), + } + } + + /// Converts wire rows into stable bridge-owned metadata and scalar values. + fn from_rows(rows: Vec, connection: &FirebirdConn, sql: &str) -> Self { + let temporal_types = result_temporal_types(sql); + let columns = rows.first().map_or_else(Vec::new, |row| { + row.cols + .iter() + .enumerate() + .map(|(index, column)| FirebirdColumn { + name: column.name.clone(), + raw_type: i64::from( + temporal_types + .get(index) + .and_then(|value| *value) + .unwrap_or(column.raw_type), + ), + }) + .collect() + }); + let rows = rows + .into_iter() + .map(|row| { + row.cols + .into_iter() + .enumerate() + .map(|(index, column)| { + let temporal_type = temporal_types.get(index).and_then(|value| *value); + let raw_type = temporal_type.unwrap_or(column.raw_type); + decode_cell(raw_type, column.value, connection, temporal_type) + }) + .collect() + }) + .collect::>(); + Self { columns, rows } + } +} + +/// Recovers DATE/TIME/TIMESTAMP result types that rsfbclient's pure-Rust +/// protocol layer normalizes to TIMESTAMP before returning row metadata. +fn result_temporal_types(sql: &str) -> Vec> { + top_level_result_expressions(sql) + .into_iter() + .map(|expression| { + let words = sql_keywords(expression); + words.windows(2).find_map(|pair| match pair { + [as_word, type_word] if as_word == "AS" && type_word == "DATE" => Some(570), + [as_word, type_word] if as_word == "AS" && type_word == "TIME" => Some(560), + [as_word, type_word] if as_word == "AS" && type_word == "TIMESTAMP" => Some(510), + _ => None, + }).or_else(|| match words.first().map(String::as_str) { + Some("DATE") => Some(570), + Some("TIME") => Some(560), + Some("TIMESTAMP") => Some(510), + _ => None, + }) + }) + .collect() +} + +/// Splits an outer SELECT or DML RETURNING projection without treating nested +/// expressions, quoted text, or comments as projection delimiters. +fn top_level_result_expressions(sql: &str) -> Vec<&str> { + let bytes = sql.as_bytes(); + let mut expressions = Vec::new(); + let mut projection_start = None; + let mut stops_at_from = false; + let mut expression_start = 0; + let mut depth = 0usize; + let mut quote = None; + let mut line_comment = false; + let mut block_comment = false; + let mut index = 0usize; + + while index < bytes.len() { + if line_comment { + if matches!(bytes[index], b'\n' | b'\r') { + line_comment = false; + } + index += 1; + continue; + } + if block_comment { + if bytes[index] == b'*' && bytes.get(index + 1) == Some(&b'/') { + block_comment = false; + index += 2; + } else { + index += 1; + } + continue; + } + if let Some(active_quote) = quote { + if bytes[index] == active_quote { + if bytes.get(index + 1) == Some(&active_quote) { + index += 2; + continue; + } + quote = None; + } + index += 1; + continue; + } + if bytes[index] == b'-' && bytes.get(index + 1) == Some(&b'-') { + line_comment = true; + index += 2; + continue; + } + if bytes[index] == b'/' && bytes.get(index + 1) == Some(&b'*') { + block_comment = true; + index += 2; + continue; + } + if matches!(bytes[index], b'\'' | b'"') { + quote = Some(bytes[index]); + index += 1; + continue; + } + match bytes[index] { + b'(' => depth += 1, + b')' => depth = depth.saturating_sub(1), + b',' if depth == 0 && projection_start.is_some() => { + expressions.push(sql[expression_start..index].trim()); + expression_start = index + 1; + } + _ if depth == 0 + && projection_start.is_none() + && (keyword_at(bytes, index, b"SELECT") + || keyword_at(bytes, index, b"RETURNING")) => + { + stops_at_from = keyword_at(bytes, index, b"SELECT"); + let keyword_len = if stops_at_from { + "SELECT".len() + } else { + "RETURNING".len() + }; + let start = index + keyword_len; + projection_start = Some(start); + expression_start = start; + index = start; + continue; + } + _ if depth == 0 + && projection_start.is_some() + && stops_at_from + && keyword_at(bytes, index, b"FROM") => + { + expressions.push(sql[expression_start..index].trim()); + return expressions; + } + _ => {} + } + index += 1; + } + + if projection_start.is_some() && expression_start < sql.len() { + expressions.push(sql[expression_start..].trim()); + } + expressions +} + +/// Reports whether an ASCII SQL keyword starts at one token boundary. +fn keyword_at(bytes: &[u8], index: usize, keyword: &[u8]) -> bool { + let Some(candidate) = bytes.get(index..index + keyword.len()) else { + return false; + }; + let boundary = |byte: Option<&u8>| { + byte.map_or(true, |byte| !byte.is_ascii_alphanumeric() && *byte != b'_') + }; + boundary(index.checked_sub(1).and_then(|previous| bytes.get(previous))) + && boundary(bytes.get(index + keyword.len())) + && candidate.eq_ignore_ascii_case(keyword) +} + +/// Converts one Firebird wire scalar using the active PDO date-format attributes. +fn decode_cell( + raw_type: u32, + value: SqlType, + connection: &FirebirdConn, + temporal_type: Option, +) -> FirebirdCell { + match value { + SqlType::Null => FirebirdCell::Null, + SqlType::Integer(value) => FirebirdCell::Int(value), + SqlType::Floating(value) => FirebirdCell::Float(value), + SqlType::Boolean(value) => FirebirdCell::Int(value as i64), + SqlType::Binary(value) => FirebirdCell::Bytes(value, true), + SqlType::Text(value) => FirebirdCell::Bytes(value.into_bytes(), false), + SqlType::Timestamp(value) => { + let format = match temporal_type.map(|raw_type| raw_type & !1) { + Some(570) => &connection.date_format, + Some(560) => &connection.time_format, + Some(510) => &connection.timestamp_format, + _ => match raw_type & !1 { + 570 => &connection.date_format, + 560 => &connection.time_format, + 510 if value.time() == chrono::NaiveTime::default() => { + &connection.date_format + } + _ => &connection.timestamp_format, + }, + }; + FirebirdCell::Bytes(value.format(format).to_string().into_bytes(), false) + } + } +} + +/// Prepared Firebird statement with PDO binding and buffered result state. +pub struct FirebirdStmt { + pub conn_id: i64, + sql: String, + named_map: HashMap, + order: Vec, + binds: Vec, + bound: Vec, + result: FirebirdResult, + cursor: isize, + executed: bool, + pub sent_sql: String, + cursor_name: Option, + error: ErrorState, +} + +impl FirebirdStmt { + /// Creates a Firebird statement and normalizes named placeholders to `?`. + pub fn new(conn_id: i64, sql: &str) -> Result { + let (translated, named_map, order, mixed) = crate::my::translate_pdo_placeholders(sql); + if mixed { + return Err("Invalid parameter number: mixed named and positional parameters".to_string()); + } + let slots = order.iter().copied().max().unwrap_or(0).max(0) as usize; + Ok(Self { + conn_id, + sql: translated, + named_map, + order, + binds: vec![SqlType::Null; slots], + bound: vec![false; slots], + result: FirebirdResult::empty(0), + cursor: -1, + executed: false, + sent_sql: String::new(), + cursor_name: None, + error: ErrorState::default(), + }) + } + + /// Resolves a named placeholder to its one-based PDO slot. + pub fn parameter_index(&self, name: &str) -> i64 { + self.named_map + .get(name.trim_start_matches(':')) + .copied() + .unwrap_or(-1) + } + + /// Stores one parameter value in a one-based slot. + fn set_bind(&mut self, index: i64, value: SqlType) -> bool { + let Some(slot) = usize::try_from(index).ok().and_then(|index| index.checked_sub(1)) else { + return false; + }; + if slot >= self.binds.len() { + return false; + } + self.binds[slot] = value; + self.bound[slot] = true; + true + } + + /// Stores an integer bind. + pub fn bind_int(&mut self, index: i64, value: i64) -> bool { + self.set_bind(index, SqlType::Integer(value)) + } + + /// Stores a floating-point bind. + pub fn bind_double(&mut self, index: i64, value: f64) -> bool { + self.set_bind(index, SqlType::Floating(value)) + } + + /// Stores a string bind. + pub fn bind_text(&mut self, index: i64, value: Vec) -> bool { + self.set_bind(index, SqlType::Text(String::from_utf8_lossy(&value).into_owned())) + } + + /// Stores a BLOB bind. + pub fn bind_blob(&mut self, index: i64, value: Vec) -> bool { + self.set_bind(index, SqlType::Binary(value)) + } + + /// Stores SQL NULL. + pub fn bind_null(&mut self, index: i64) -> bool { + self.set_bind(index, SqlType::Null) + } + + /// Clears execution state while retaining binds. + pub fn reset(&mut self) { + self.result = FirebirdResult::empty(0); + self.cursor = -1; + self.executed = false; + self.sent_sql.clear(); + self.error = ErrorState::default(); + } + + /// Clears execution state and every bound parameter. + pub fn clear_bindings(&mut self) { + self.reset(); + self.binds.fill(SqlType::Null); + self.bound.fill(false); + } + + /// Executes with parameters expanded into native occurrence order. + pub fn execute(&mut self, connection: &mut FirebirdConn) -> Result<(), String> { + if self.bound.iter().any(|bound| !bound) { + self.error = ErrorState { + sqlstate: "HY093".to_string(), + native_code: 0, + message: "Invalid parameter number: number of bound variables does not match number of tokens".to_string(), + }; + return Err(self.error.message.clone()); + } + let params = self + .order + .iter() + .filter_map(|slot| usize::try_from(*slot).ok()?.checked_sub(1)) + .map(|slot| self.binds[slot].clone()) + .collect(); + self.sent_sql = self.sql.clone(); + match connection.execute(&self.sql, params) { + Ok(result) => { + self.result = result; + self.cursor = -1; + self.executed = true; + self.error = ErrorState::default(); + Ok(()) + } + Err(message) => { + self.error = connection.error.clone(); + Err(message) + } + } + } + + /// Reports whether the statement still needs its first execution. + pub fn needs_execute(&self) -> bool { + !self.executed + } + + /// Advances to the next buffered row. + pub fn step(&mut self) -> i64 { + let next = self.cursor + 1; + if next < self.result.rows.len() as isize { + self.cursor = next; + 1 + } else { + 0 + } + } + + /// Returns the current result row. + fn row(&self) -> Option<&[FirebirdCell]> { + usize::try_from(self.cursor) + .ok() + .and_then(|index| self.result.rows.get(index)) + .map(Vec::as_slice) + } + + /// Returns one current cell. + fn cell(&self, index: usize) -> Option<&FirebirdCell> { + self.row()?.get(index) + } + + /// Returns the common bridge type tag for one current cell. + pub fn column_type(&self, index: i64) -> i64 { + let Ok(index) = usize::try_from(index) else { + return 5; + }; + match self.cell(index) { + Some(FirebirdCell::Int(_)) => 1, + Some(FirebirdCell::Float(_)) => 2, + Some(FirebirdCell::Bytes(_, true)) => 4, + Some(FirebirdCell::Bytes(_, false)) => 3, + Some(FirebirdCell::Null) | None => 5, + } + } + + /// Returns one current cell as an integer. + pub fn column_int(&self, index: i64) -> i64 { + let Ok(index) = usize::try_from(index) else { + return 0; + }; + match self.cell(index) { + Some(FirebirdCell::Int(value)) => *value, + Some(FirebirdCell::Float(value)) => *value as i64, + Some(FirebirdCell::Bytes(value, _)) => String::from_utf8_lossy(value).parse().unwrap_or(0), + Some(FirebirdCell::Null) | None => 0, + } + } + + /// Returns one current cell as a double. + pub fn column_double(&self, index: i64) -> f64 { + let Ok(index) = usize::try_from(index) else { + return 0.0; + }; + match self.cell(index) { + Some(FirebirdCell::Int(value)) => *value as f64, + Some(FirebirdCell::Float(value)) => *value, + Some(FirebirdCell::Bytes(value, _)) => String::from_utf8_lossy(value).parse().unwrap_or(0.0), + Some(FirebirdCell::Null) | None => 0.0, + } + } + + /// Returns one current cell as bytes. + pub fn column_data(&self, index: i64) -> Vec { + let Ok(index) = usize::try_from(index) else { + return Vec::new(); + }; + match self.cell(index) { + Some(FirebirdCell::Int(value)) => value.to_string().into_bytes(), + Some(FirebirdCell::Float(value)) => value.to_string().into_bytes(), + Some(FirebirdCell::Bytes(value, _)) => value.clone(), + Some(FirebirdCell::Null) | None => Vec::new(), + } + } + + /// Returns the result column count. + pub fn column_count(&self) -> i64 { + self.result.columns.len() as i64 + } + + /// Returns one result column name. + pub fn column_name(&self, index: i64) -> String { + usize::try_from(index) + .ok() + .and_then(|index| self.result.columns.get(index)) + .map(|column| column.name.clone()) + .unwrap_or_default() + } + + /// Returns one result column's Firebird wire type ID. + pub fn column_raw_type(&self, index: i64) -> i64 { + usize::try_from(index) + .ok() + .and_then(|index| self.result.columns.get(index)) + .map_or(0, |column| column.raw_type) + } + + /// Returns php-src's PDO_FIREBIRD native type name. + pub fn column_native_type(&self, index: i64) -> String { + firebird_type_name(self.column_raw_type(index)).to_string() + } + + /// Returns php-src PDO_FIREBIRD's sole `getColumnMeta()` field for one column. + pub fn column_pdo_type(&self, index: i64) -> i64 { + let Some(index) = usize::try_from(index).ok() else { + return 2; + }; + let Some(column) = self.result.columns.get(index) else { + return 2; + }; + if column.raw_type & !1 == 32764 { + return 5; + } + match self.result.rows.first().and_then(|row| row.get(index)) { + Some(FirebirdCell::Int(_)) if matches!(column.raw_type & !1, 496 | 500 | 580) => 1, + _ => 2, + } + } + + /// Stores the PDO-visible Firebird cursor name after validating its native limit. + pub fn set_cursor_name(&mut self, name: String) -> bool { + if name.len() > 31 { + self.error = ErrorState { + sqlstate: "HY000".to_string(), + native_code: 0, + message: "Cursor name must not be longer than 31 bytes".to_string(), + }; + return false; + } + self.cursor_name = Some(name); + true + } + + /// Returns the configured Firebird cursor name, or `None` before one is set. + pub fn cursor_name(&self) -> Option<&str> { + self.cursor_name.as_deref() + } + + /// Returns the statement SQLSTATE. + pub fn sqlstate(&self) -> &str { + &self.error.sqlstate + } + + /// Returns the statement native SQLCODE. + pub fn errcode(&self) -> i64 { + self.error.native_code + } + + /// Returns the statement diagnostic text. + pub fn errmsg(&self) -> &str { + &self.error.message + } +} + +/// Maps Firebird's XSQLDA type IDs to stable PDO metadata spellings. +pub fn firebird_type_name(raw_type: i64) -> &'static str { + match raw_type & !1 { + 448 => "VARCHAR", + 452 => "CHAR", + 480 | 482 | 530 => "DOUBLE", + 496 => "INTEGER", + 500 => "SMALLINT", + 510 => "TIMESTAMP", + 520 => "BLOB", + 540 => "ARRAY", + 550 => "QUAD", + 560 => "TIME", + 570 => "DATE", + 580 => "BIGINT", + 32764 => "BOOLEAN", + _ => "UNKNOWN", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Parses the php-src Firebird DSN keys and constructor options. + #[test] + fn parses_firebird_dsn() { + let options = parse_dsn("firebird:host=db;port=3051;dbname=/data/app.fdb;charset=UTF8;role=ADMIN;dialect=3;user=user%3Bname;password=p%25w;transaction_isolation=1004;writable_transaction=0").unwrap(); + assert_eq!(options.host, "db"); + assert_eq!(options.port, 3051); + assert_eq!(options.dbname, "/data/app.fdb"); + assert_eq!(options.user, "user;name"); + assert_eq!(options.password, "p%w"); + assert_eq!(options.isolation, READ_COMMITTED); + assert!(!options.writable); + } + + /// Accepts the legacy remote dbname syntax documented by PDO_FIREBIRD. + #[test] + fn parses_php_remote_dbname() { + let options = parse_dsn( + "firebird:dbname=db.example/3051:/data/app.fdb;charset=utf-8;user=test;password=secret", + ) + .unwrap(); + assert_eq!(options.host, "db.example"); + assert_eq!(options.port, 3051); + assert_eq!(options.dbname, "/data/app.fdb"); + } + + /// Accepts Firebird 3+'s URL-style IPv6 connection strings. + #[test] + fn parses_url_style_ipv6_dbname() { + let options = parse_dsn("firebird:dbname=inet6://[::1]:3052/app.fdb").unwrap(); + assert_eq!(options.host, "::1"); + assert_eq!(options.port, 3052); + assert_eq!(options.dbname, "app.fdb"); + } + + /// Rejects a transaction isolation constant outside php-src's supported set. + #[test] + fn rejects_invalid_isolation() { + assert!(parse_dsn("firebird:dbname=test.fdb;transaction_isolation=9999").is_err()); + } + + /// Maps common Firebird errors into PDO SQLSTATE classes. + #[test] + fn maps_firebird_sqlstates() { + assert_eq!(sqlstate_for_code(-803), "23000"); + assert_eq!(sqlstate_for_code(-204), "42S02"); + assert_eq!(sqlstate_for_code(-104), "42000"); + assert_eq!(sqlstate_for_code(-902), "08006"); + } + + /// Mirrors Firebird XSQLDA type names used by column metadata. + #[test] + fn maps_firebird_type_names() { + assert_eq!(firebird_type_name(497), "INTEGER"); + assert_eq!(firebird_type_name(521), "BLOB"); + assert_eq!(firebird_type_name(32765), "BOOLEAN"); + } + + /// Ignores comments and literals when classifying statement keywords. + #[test] + fn scans_executable_sql_keywords() { + assert_eq!(leading_keyword("/* hint */ -- line\n SELECT 1"), "SELECT"); + assert!(has_sql_keyword("INSERT INTO T VALUES (1)\nRETURNING ID", "RETURNING")); + assert!(!has_sql_keyword("INSERT INTO T VALUES (' RETURNING ')", "RETURNING")); + } + + /// Recovers normalized temporal types from each outer SELECT expression. + #[test] + fn recovers_temporal_cast_types_from_select_projection() { + assert_eq!( + result_temporal_types( + "WITH seed AS (SELECT 1 FROM RDB$DATABASE) \ + SELECT CAST('2024-02-03' AS DATE) AS d, \ + COALESCE(CAST('12:34:56' AS TIME), CAST('00:00:00' AS TIME)) AS t, \ + CAST('2024-02-03 00:00:00' AS TIMESTAMP) AS ts, \ + 'FROM, AS DATE' AS label \ + FROM seed", + ), + vec![Some(570), Some(560), Some(510), None] + ); + assert_eq!( + result_temporal_types( + "UPDATE events SET touched = CURRENT_TIMESTAMP \ + RETURNING DATE '2024-02-03', TIME '12:34:56', \ + CAST(touched AS TIMESTAMP)", + ), + vec![Some(570), Some(560), Some(510)] + ); + } + + /// Runs a direct Firebird round-trip when an explicit live DSN is provided. + #[test] + #[ignore] + fn live_round_trip() { + let dsn = std::env::var("ELEPHC_FIREBIRD_DSN") + .expect("ELEPHC_FIREBIRD_DSN is required for the ignored live test"); + let mut connection = FirebirdConn::open(&dsn) + .unwrap_or_else(|error| panic!("PDO_FIREBIRD connection failed: {error}")); + let result = connection + .execute("SELECT CAST(7 AS INTEGER) AS N, CAST('Ada' AS VARCHAR(10)) AS NAME FROM RDB$DATABASE", Vec::new()) + .unwrap_or_else(|error| panic!("PDO_FIREBIRD query failed: {error}")); + assert_eq!(result.columns.len(), 2); + assert!(matches!(result.rows[0][0], FirebirdCell::Int(7))); + assert!(matches!(&result.rows[0][1], FirebirdCell::Bytes(value, false) if value == b"Ada")); + } +} diff --git a/crates/elephc-pdo/src/ini.rs b/crates/elephc-pdo/src/ini.rs new file mode 100644 index 0000000000..76f075014d --- /dev/null +++ b/crates/elephc-pdo/src/ini.rs @@ -0,0 +1,207 @@ +//! Purpose: +//! Runtime discovery and parsing of PHP INI PDO DSN aliases. +//! +//! Called from: +//! - `elephc_pdo_ini_dsn_defined()` and `elephc_pdo_ini_dsn_value()` in the bridge root. +//! +//! Key details: +//! - Main `PHPRC` configuration loads before alphabetically sorted scan fragments. +//! - Directive names are case-sensitive and later `pdo.dsn.*` assignments win. + +use std::collections::HashMap; +use std::ffi::OsStr; +use std::fs; +use std::path::PathBuf; +use std::sync::OnceLock; + +/// Returns the process-startup PDO alias map, loading it on first PDO use. +fn aliases() -> &'static HashMap { + static ALIASES: OnceLock> = OnceLock::new(); + ALIASES.get_or_init(load_aliases) +} + +/// Looks up a case-sensitive `pdo.dsn.` alias by its short DSN name. +pub(crate) fn lookup(name: &str) -> Option<&'static str> { + aliases().get(name).map(String::as_str) +} + +/// Loads aliases from the main PHP configuration and every configured scan fragment. +fn load_aliases() -> HashMap { + let phprc = std::env::var_os("PHPRC"); + let scan = std::env::var_os("PHP_INI_SCAN_DIR"); + let mut result = HashMap::new(); + for path in configuration_files(phprc.as_deref(), scan.as_deref()) { + let Ok(contents) = fs::read_to_string(path) else { + continue; + }; + parse_aliases(&contents, &mut result); + } + result +} + +/// Resolves the ordered PHP configuration files for explicit portable runtime sources. +fn configuration_files(phprc: Option<&OsStr>, scan: Option<&OsStr>) -> Vec { + let mut files = Vec::new(); + if let Some(raw) = phprc.filter(|value| !value.is_empty()) { + let path = PathBuf::from(raw); + let candidate = if path.is_dir() { path.join("php.ini") } else { path }; + if candidate.is_file() { + files.push(candidate); + } + } + + if let Some(raw) = scan { + for directory in std::env::split_paths(raw) { + if directory.as_os_str().is_empty() { + continue; + } + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + let mut fragments: Vec = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.is_file() + && path.extension().and_then(OsStr::to_str) == Some("ini") + }) + .collect(); + fragments.sort(); + files.extend(fragments); + } + } + files +} + +/// Applies PDO alias assignments from one INI document to `aliases`. +fn parse_aliases(contents: &str, aliases: &mut HashMap) { + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with(';') || line.starts_with('#') || line.starts_with('[') { + continue; + } + let Some((raw_key, raw_value)) = line.split_once('=') else { + continue; + }; + let key = raw_key.trim(); + let Some(name) = key.strip_prefix("pdo.dsn.") else { + continue; + }; + if name.is_empty() { + continue; + } + aliases.insert(name.to_string(), parse_value(raw_value)); + } +} + +/// Parses the quoted or unquoted scalar subset used by PHP PDO DSN directives. +fn parse_value(raw: &str) -> String { + let value = raw.trim_start(); + let Some(quote) = value.chars().next().filter(|ch| *ch == '\'' || *ch == '"') else { + let unquoted = value + .split_once(';') + .map_or(value, |(before, _)| before) + .trim_end(); + return expand_environment(unquoted); + }; + + let mut parsed = String::new(); + let mut escaped = false; + for ch in value[quote.len_utf8()..].chars() { + if escaped { + if ch == quote || ch == '\\' { + parsed.push(ch); + } else { + parsed.push('\\'); + parsed.push(ch); + } + escaped = false; + } else if ch == '\\' && quote == '"' { + escaped = true; + } else if ch == quote { + return if quote == '"' { + expand_environment(&parsed) + } else { + parsed + }; + } else { + parsed.push(ch); + } + } + if escaped { + parsed.push('\\'); + } + if quote == '"' { + expand_environment(&parsed) + } else { + parsed + } +} + +/// Expands PHP INI `${NAME}` environment references, leaving missing variables empty. +fn expand_environment(value: &str) -> String { + let mut expanded = String::new(); + let mut rest = value; + while let Some(start) = rest.find("${") { + expanded.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let Some(end) = after.find('}') else { + expanded.push_str(&rest[start..]); + return expanded; + }; + expanded.push_str(&std::env::var(&after[..end]).unwrap_or_default()); + rest = &after[end + 1..]; + } + expanded.push_str(rest); + expanded +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Parses quoted semicolons, ignores unrelated directives, and keeps the last alias. + #[test] + fn parses_pdo_aliases_with_last_assignment_winning() { + let mut aliases = HashMap::new(); + parse_aliases( + r#" + pdo.dsn.main = "mysql:host=one;dbname=db" + unrelated = value + pdo.dsn.main = 'sqlite::memory:' ; replacement + PDO.DSN.UPPER = "pgsql:host=wrong" + "#, + &mut aliases, + ); + assert_eq!(aliases.get("main").map(String::as_str), Some("sqlite::memory:")); + assert!(!aliases.contains_key("UPPER")); + } + + /// Preserves semicolons inside quotes and strips unquoted trailing comments. + #[test] + fn parses_quoted_and_unquoted_values() { + assert_eq!(parse_value("\"mysql:host=db;dbname=app\" ; note"), "mysql:host=db;dbname=app"); + assert_eq!(parse_value(" sqlite::memory: ; note"), "sqlite::memory:"); + assert_eq!(parse_value("\"sqlite:file\\nname\""), "sqlite:file\\nname"); + } + + /// Orders a PHPRC main file before alphabetically sorted scan fragments. + #[test] + fn configuration_order_matches_php_precedence() { + let root = std::env::temp_dir().join(format!( + "elephc_pdo_ini_{}_configuration_order", + std::process::id() + )); + let scan = root.join("scan"); + fs::create_dir_all(&scan).unwrap(); + let main = root.join("php.ini"); + fs::write(&main, "pdo.dsn.db=sqlite:main").unwrap(); + fs::write(scan.join("20-last.ini"), "pdo.dsn.db=sqlite:last").unwrap(); + fs::write(scan.join("10-first.ini"), "pdo.dsn.db=sqlite:first").unwrap(); + fs::write(scan.join("ignored.txt"), "pdo.dsn.db=sqlite:ignored").unwrap(); + + let paths = configuration_files(Some(main.as_os_str()), Some(scan.as_os_str())); + assert_eq!(paths, [main, scan.join("10-first.ini"), scan.join("20-last.ini")]); + let _ = fs::remove_dir_all(root); + } +} diff --git a/crates/elephc-pdo/src/lib.rs b/crates/elephc-pdo/src/lib.rs index 3145d2fd08..7d7e5762aa 100644 --- a/crates/elephc-pdo/src/lib.rs +++ b/crates/elephc-pdo/src/lib.rs @@ -2,8 +2,8 @@ //! Multi-driver database bridge for the elephc PDO implementation. Exposes a //! small, stable, driver-agnostic C ABI (`elephc_pdo_*`) that the elephc PDO //! prelude calls through `extern "elephc_pdo"` declarations; each call dispatches -//! to the SQLite, PostgreSQL, or MySQL/MariaDB driver based on the handle's -//! driver, selected from the DSN prefix at `open()`. +//! to a registered PDO driver based on the handle's driver, selected from the +//! DSN prefix at `open()`. //! //! Called from: //! - Compiled PHP programs that use PDO, via the elephc-PHP prelude's `extern` @@ -21,28 +21,100 @@ //! - Fallible entry points collapse failure to a `-1`/`0` sentinel. String //! results return `*const c_char` into a per-result static buffer that elephc //! copies into an owned PHP string immediately on return. -//! - The drivers are bundled (SQLite) / pure-Rust (PostgreSQL, MySQL/MariaDB), so -//! compiled PHP binaries have no system database-client runtime dependency. +//! - Every `extern "C"` entry point runs its body inside `ffi_guard`, a +//! `catch_unwind` panic firewall (F-QUAL-02), and takes every table lock through +//! `lock_recover`. Without the pair, one panic under a `conns()`/`stmts()` lock +//! would poison that mutex and brick PDO for the whole process, and the unwind +//! out of a plain `extern "C"` function would abort the compiled program instead +//! of surfacing a catchable `PDOException`. +//! - The default drivers are bundled (SQLite) / pure-Rust (PostgreSQL, +//! MySQL/MariaDB), so their binaries have no system database-client runtime +//! dependency. The optional PDO_DBLIB profile links FreeTDS like php-src; +//! PDO_FIREBIRD uses the pure-Rust Firebird wire protocol on every target, +//! PDO_ODBC, PDO_INFORMIX, PDO_IBM, and PDO_SQLSRV link the system driver manager, while +//! PDO_OCI loads Oracle Instant Client dynamically through ODPI-C, and PDO_CUBRID +//! loads the official CCI client dynamically. +mod driver; +#[cfg(feature = "cubrid")] +mod cubrid; +#[cfg(feature = "dblib")] +mod dblib; +#[cfg(feature = "firebird")] +mod firebird; +mod ini; mod my; +#[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] +mod odbc; +#[cfg(feature = "oci")] +mod oci; +#[path = "pg.rs"] +#[cfg_attr(feature = "libpq-gss", allow(dead_code))] +mod pg_native; +#[cfg(feature = "libpq-gss")] +#[path = "pg_libpq.rs"] mod pg; +#[cfg(not(feature = "libpq-gss"))] +use pg_native as pg; mod sqlite; +use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::ffi::{CStr, CString}; -use std::os::raw::c_char; +use std::os::raw::{c_char, c_void}; use std::sync::atomic::{AtomicI64, Ordering}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Mutex, MutexGuard, OnceLock}; /// A live connection, tagged by its driver. enum Conn { + #[cfg(feature = "cubrid")] + Cubrid(cubrid::CubridConn), + #[cfg(feature = "dblib")] + Dblib(dblib::DblibConn), + #[cfg(feature = "firebird")] + Firebird(firebird::FirebirdConn), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Odbc(odbc::OdbcConn), + #[cfg(feature = "oci")] + Oci(oci::OciConn), Sqlite(sqlite::SqliteConn), Postgres(pg::PgConn), Mysql(my::MyConn), } +impl Conn { + /// Returns the central registry identity for this live connection. + fn driver_kind(&self) -> driver::DriverKind { + match self { + #[cfg(feature = "cubrid")] + Self::Cubrid(_) => driver::DriverKind::Cubrid, + #[cfg(feature = "dblib")] + Self::Dblib(_) => driver::DriverKind::Dblib, + #[cfg(feature = "firebird")] + Self::Firebird(_) => driver::DriverKind::Firebird, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Self::Odbc(connection) => connection.driver_kind(), + #[cfg(feature = "oci")] + Self::Oci(_) => driver::DriverKind::Oci, + Self::Sqlite(_) => driver::DriverKind::Sqlite, + Self::Postgres(_) => driver::DriverKind::Pgsql, + Self::Mysql(_) => driver::DriverKind::Mysql, + } + } +} + /// A live prepared statement, tagged by its driver. enum Stmt { + #[cfg(feature = "cubrid")] + Cubrid(cubrid::CubridStmt), + #[cfg(feature = "dblib")] + Dblib(dblib::DblibStmt), + #[cfg(feature = "firebird")] + Firebird(firebird::FirebirdStmt), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Odbc(odbc::OdbcStmt), + #[cfg(feature = "oci")] + Oci(oci::OciStmt), Sqlite(sqlite::SqliteStmt), Postgres(pg::PgStmt), Mysql(my::MyStmt), @@ -60,20 +132,43 @@ fn stmts() -> &'static Mutex> { STMTS.get_or_init(|| Mutex::new(HashMap::new())) } -/// Process-local persistent connection pool, keyed by the fully materialized -/// DSN passed into the bridge after constructor credentials have been folded in. -fn persistent_conns() -> &'static Mutex> { - static PERSISTENT_CONNS: OnceLock>> = OnceLock::new(); +/// Process-local persistent connection pool, keyed by the pair of the fully +/// materialized DSN passed into the bridge (after constructor credentials have been +/// folded in) and the caller's `PDO::ATTR_PERSISTENT` key string. +/// +/// The key string is the second half of the key because php-src's persistent +/// hashkey is built from the DSN *and* that string whenever `ATTR_PERSISTENT` was +/// given as a non-numeric, non-empty string (`pdo_dbh.c:389-404`) — so two +/// persistent connections to the SAME DSN under DIFFERENT key strings are distinct +/// pooled entries, and only a plain boolean-persistent open (key `""`, F-CORE-16) +/// pools by DSN alone. Keying on the DSN by itself, as this did, wrongly collapsed +/// two differently-named pools onto one shared connection. +fn persistent_conns() -> &'static Mutex> { + static PERSISTENT_CONNS: OnceLock>> = OnceLock::new(); PERSISTENT_CONNS.get_or_init(|| Mutex::new(HashMap::new())) } -/// Set of connection handles owned by the persistent pool. `elephc_pdo_close` -/// leaves these handles open so later persistent opens can reuse them. +/// Set of connection handles owned by the persistent pool. Release decrements +/// ownership but leaves these handles open for later checkout. fn persistent_ids() -> &'static Mutex> { static PERSISTENT_IDS: OnceLock>> = OnceLock::new(); PERSISTENT_IDS.get_or_init(|| Mutex::new(HashSet::new())) } +/// Counts live PDO objects currently owning each pooled connection handle. +/// A zero count keeps the native session cached but makes it eligible for the +/// PHP 8.6 PostgreSQL disconnect-equivalent `DISCARD ALL` reset. +fn persistent_owner_counts() -> &'static Mutex> { + static COUNTS: OnceLock>> = OnceLock::new(); + COUNTS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Serializes persistent checkout, liveness validation, eviction, and reconnect. +fn persistent_checkout_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + /// Returns a fresh, never-reused handle ID. IDs start at 1 so `0` and `-1` /// remain available as "absent" / "error" sentinels. fn next_id() -> i64 { @@ -81,12 +176,116 @@ fn next_id() -> i64 { NEXT.fetch_add(1, Ordering::SeqCst) } +/// Runs an FFI entry-point body, converting any panic into `fallback` so a panic +/// never unwinds across the C ABI boundary (F-QUAL-02). These entry points are plain +/// `extern "C"` (not `extern "C-unwind"`), so on rustc ≥ 1.81 an unwinding panic out +/// of one ABORTS the whole compiled PHP process — over an internal `unwrap`, a +/// debug-build overflow, or an unexpected panic from the `postgres`/`mysql` client +/// crates. Catching it here degrades the call into the same well-defined "failed" +/// answer the entry point's own docblock already promises for an unknown handle +/// (`-1`/`0`, the empty string, …), which the prelude turns into a catchable +/// `PDOException`. Mirrors the same pair in `elephc-image` / `elephc-phar`. +/// +/// `AssertUnwindSafe` is sound here: the bodies touch only the process-global handle +/// tables, each guarded by its own `Mutex`, and a lock poisoned by a caught panic is +/// reclaimed by [`lock_recover`] rather than re-panicking — so a caught panic can +/// leave a table logically stale, never memory-unsafe. +/// +/// `pub(crate)` (like `elephc-image`'s) so the driver modules can guard their own +/// externs too: `sqlite::elephc_pdo_udf_stash_bytes` is the one entry point outside +/// this file, and it is guarded as well — the firewall covers every `#[no_mangle]` body +/// in the crate without exception. +pub(crate) fn ffi_guard(fallback: T, body: impl FnOnce() -> T) -> T { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) { + Ok(value) => value, + Err(_) => fallback, + } +} + +/// Locks a process-global table, recovering the guard if a previously caught panic +/// poisoned the mutex. This is the other half of the [`ffi_guard`] firewall: once a +/// panic escapes a body that held a `conns()`/`stmts()` lock, that mutex stays +/// poisoned forever, and a plain `.lock().unwrap()` would then panic on EVERY later +/// PDO call in the process — unrelated connections included — each of those panics +/// aborting across the C ABI. The payload is still structurally valid (the tables are +/// plain maps, the cells plain buffers), so reusing it lets the bridge keep serving. +/// `pub(crate)` for the same reason as [`ffi_guard`]. +pub(crate) fn lock_recover(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Returns a pointer to a `'static` NUL-terminated C string literal. Used as the +/// [`ffi_guard`] fallback of the `*const c_char` entry points, which must hand back a +/// readable C string even in the panic path. It deliberately does NOT route through +/// the per-result static cells: the panic being caught may have happened while one of +/// those cells was locked or half-written, so the fallback must not depend on any +/// state the panicking body could have touched. +fn static_cstr(bytes: &'static [u8]) -> *const c_char { + debug_assert_eq!( + bytes.last(), + Some(&0), + "static_cstr needs a NUL-terminated literal" + ); + bytes.as_ptr() as *const c_char +} + /// Static buffer holding the last message captured by a failed `elephc_pdo_open`. fn open_error_cell() -> &'static Mutex { static C: OnceLock> = OnceLock::new(); C.get_or_init(|| Mutex::new(CString::default())) } +/// Static SQLSTATE captured alongside the last failed connection open. +fn open_sqlstate_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Native driver code captured alongside the last failed connection open. +fn open_native_code_cell() -> &'static AtomicI64 { + static CODE: AtomicI64 = AtomicI64::new(0); + &CODE +} + +/// Stores a failed-open message and any driver-specific constructor diagnostic. +fn store_open_failure(dsn: &str, message: &str) { + store_cstr(open_error_cell(), message); + let (sqlstate, native_code) = if dsn.starts_with("cubrid:") { + #[cfg(feature = "cubrid")] + { + let (state, code) = cubrid::open_diagnostic(); + (state.to_string(), code) + } + #[cfg(not(feature = "cubrid"))] + { + (String::new(), 0) + } + } else if dsn.starts_with("oci:") { + #[cfg(feature = "oci")] + { + let (state, code) = oci::open_diagnostic(message); + (state.to_string(), code) + } + #[cfg(not(feature = "oci"))] + { + (String::new(), 0) + } + } else if dsn.starts_with("odbc:") || dsn.starts_with("informix:") || dsn.starts_with("ibm:") { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + { + odbc::open_diagnostic() + } + #[cfg(not(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv")))] + { + (String::new(), 0) + } + } else { + (String::new(), 0) + }; + store_cstr(open_sqlstate_cell(), &sqlstate); + open_native_code_cell().store(native_code, Ordering::Relaxed); +} + /// Static buffer for the most recent `elephc_pdo_errmsg` result. fn errmsg_cell() -> &'static Mutex { static C: OnceLock> = OnceLock::new(); @@ -99,16 +298,45 @@ fn colname_cell() -> &'static Mutex { C.get_or_init(|| Mutex::new(CString::default())) } -/// Static buffer for the most recent `elephc_pdo_column_text` result. -fn coltext_cell() -> &'static Mutex { +/// Static buffer for the most recent `elephc_pdo_column_table_name` result. +fn table_name_cell() -> &'static Mutex { static C: OnceLock> = OnceLock::new(); C.get_or_init(|| Mutex::new(CString::default())) } -/// Static byte buffer for the most recent `elephc_pdo_column_data_ptr` result. -fn coldata_cell() -> &'static Mutex> { - static C: OnceLock>> = OnceLock::new(); - C.get_or_init(|| Mutex::new(Vec::new())) +/// Static buffer for the most recent `elephc_pdo_column_decltype` result. +fn decltype_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Static buffer for the most recent `elephc_pdo_column_native_type` result. +fn native_type_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Static buffer holding PDO_CUBRID's current result-column default value. +fn cubrid_column_default_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Return buffer for PDO_DBLIB column-source metadata. +fn dblib_column_source_cell() -> &'static Mutex { + static CELL: OnceLock> = OnceLock::new(); + CELL.get_or_init(|| Mutex::new(CString::new("").unwrap())) +} + +/// Static buffer for the most recent emulated statement SQL result. +fn stmt_sent_sql_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +thread_local! { + /// Per-thread byte buffer for the most recent `elephc_pdo_column_data_ptr` result. + static COLDATA_CELL: RefCell> = const { RefCell::new(Vec::new()) }; } /// Static buffer for the most recent `elephc_pdo_driver_name` result. @@ -117,28 +345,136 @@ fn drivername_cell() -> &'static Mutex { C.get_or_init(|| Mutex::new(CString::default())) } +/// Static buffer for the most recently resolved `pdo.dsn.*` INI alias. +fn dsn_alias_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Static buffer for the most recent `elephc_pdo_sqlstate` result. +fn sqlstate_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Static buffer for the most recent `elephc_pdo_stmt_sqlstate` result. +fn stmt_sqlstate_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Static buffer for the most recent `elephc_pdo_stmt_errmsg` result. +fn stmt_errmsg_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Return buffer for PDO_DBLIB connection operating-system diagnostics. +fn dblib_os_errmsg_cell() -> &'static Mutex { + static CELL: OnceLock> = OnceLock::new(); + CELL.get_or_init(|| Mutex::new(CString::new("").unwrap())) +} + +/// Return buffer for PDO_DBLIB statement operating-system diagnostics. +fn dblib_stmt_os_errmsg_cell() -> &'static Mutex { + static CELL: OnceLock> = OnceLock::new(); + CELL.get_or_init(|| Mutex::new(CString::new("").unwrap())) +} + +/// Shared return buffer for textual PDO_FIREBIRD attributes. +fn firebird_attribute_cell() -> &'static Mutex { + static CELL: OnceLock> = OnceLock::new(); + CELL.get_or_init(|| Mutex::new(CString::new("").unwrap())) +} + +/// Static buffer for the most recent PDO_IBM string-valued attribute result. +fn ibm_attribute_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Stores the latest SQLSRV sensitivity label/information-type text return. +fn sqlsrv_classification_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::new("").unwrap())) +} + +/// Static buffer for the most recent `elephc_pdo_server_version` result. +fn server_version_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Static buffer for the most recent `elephc_pdo_client_version` result. +fn client_version_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Static buffer for the most recent `elephc_pdo_server_info` result. +fn server_info_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Static buffer for the most recent `elephc_pdo_connection_status` result. +fn connection_status_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Static buffer for the most recent `elephc_pdo_last_insert_id_text` result. +fn last_insert_id_text_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Static buffer for the most recent PostgreSQL text result returned to PHP +/// (`elephc_pdo_lob_create` / `elephc_pdo_copy_out`). Shared because each result is +/// copied into an owned PHP string before the next call writes the cell. +fn pg_text_result_cell() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(CString::default())) +} + +/// Static byte buffer for the most recent whole or bounded BLOB / large-object read +/// (`elephc_pdo_blob_read_at`, `elephc_pdo_lob_read_at`, and their legacy whole-value +/// variants), bulk-copied out through +/// `elephc_pdo_blob_data_ptr` (or, on the fallback path, drained byte-by-byte through +/// `elephc_pdo_blob_byte`). A `Vec` rather than a `CString` because BLOBs are +/// binary and may contain embedded NUL bytes; shared because the prelude copies each +/// result into a PHP string (wrapped in a `php://memory` stream) before the next read +/// overwrites the cell. +fn blob_cell() -> &'static Mutex> { + static C: OnceLock>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(Vec::new())) +} + /// Stores `s` (NUL bytes stripped) into the per-result static `cell` and returns /// a pointer into it. Valid until the next call writing the same cell; elephc /// copies it into an owned PHP string on return. fn store_cstr(cell: &'static Mutex, s: &str) -> *const c_char { let bytes: Vec = s.bytes().filter(|&b| b != 0).collect(); let cstr = CString::new(bytes).unwrap_or_default(); - let mut guard = cell.lock().unwrap(); + let mut guard = lock_recover(cell); *guard = cstr; guard.as_ptr() } -/// Stores raw bytes into the per-result static data buffer and returns a pointer +/// Stores raw bytes into the current thread's result buffer and returns a pointer /// to the first byte, or null for an empty buffer. Valid until the next column -/// data pointer call; elephc copies it immediately through `ptr_read_string`. +/// data pointer call on that thread; elephc copies it immediately through +/// `ptr_read_string`. fn store_bytes(bytes: Vec) -> *const c_char { - let mut guard = coldata_cell().lock().unwrap(); - *guard = bytes; - if guard.is_empty() { - std::ptr::null() - } else { - guard.as_ptr() as *const c_char - } + COLDATA_CELL.with(|cell| { + let mut buffer = cell.borrow_mut(); + *buffer = bytes; + if buffer.is_empty() { + std::ptr::null() + } else { + buffer.as_ptr() as *const c_char + } + }) } /// Reads a null-terminated C string argument as a `&str` (the shape elephc's @@ -154,752 +490,5904 @@ unsafe fn cstr_arg<'a>(p: *const c_char) -> Option<&'a str> { CStr::from_ptr(p).to_str().ok() } -/// Opens the driver connection for a validated DSN string. -fn open_conn_for_dsn(dsn: &str) -> Result { - if let Some(path) = dsn.strip_prefix("sqlite:") { - sqlite::SqliteConn::open(path).map(Conn::Sqlite) - } else if dsn.starts_with("pgsql:") { - pg::PgConn::open(dsn).map(Conn::Postgres) - } else if dsn.starts_with("mysql:") { - my::MyConn::open(dsn).map(Conn::Mysql) - } else { - Err( - "could not find driver (only sqlite:, pgsql:, and mysql: DSNs are supported)" - .to_string(), +/// Reads a raw byte-buffer argument (the shape elephc's `extern …` pointer + +/// length parameters marshal to) into an owned `Vec`. Returns an empty vector +/// for a null pointer or a non-positive length; unlike `cstr_arg` this preserves +/// embedded NUL bytes and does not require valid UTF-8. +/// +/// # Safety +/// `p`, when non-null, must point to at least `len` readable bytes valid for the +/// call. +unsafe fn bytes_arg(p: *const c_char, len: i64) -> Vec { + if p.is_null() || len <= 0 { + return Vec::new(); + } + std::slice::from_raw_parts(p as *const u8, len as usize).to_vec() +} + +/// Opens the driver connection for a validated DSN string. `sqlite_open_flags` +/// (P1-10/P2-9) is only consulted for a `sqlite:` DSN; `my_init_command` (P1-9), +/// `my_ssl_config` (the packed `Pdo\Mysql::ATTR_SSL_*` options) and `my_found_rows` +/// (`Pdo\Mysql::ATTR_FOUND_ROWS`, F-MY-06) only for a `mysql:` DSN. PostgreSQL reads +/// its own `sslmode`/`sslrootcert` straight from the DSN, so it takes no extra +/// parameter here; the other driver's parameters are ignored. +fn open_conn_for_dsn( + dsn: &str, + sqlite_open_flags: i64, + my_init_command: &str, + my_ssl_config: &str, + my_found_rows: bool, + my_driver_config: &str, +) -> Result { + match driver::DriverKind::from_dsn(dsn) { + #[cfg(feature = "cubrid")] + Some(driver::DriverKind::Cubrid) => cubrid::CubridConn::open(dsn).map(Conn::Cubrid), + #[cfg(feature = "dblib")] + Some(driver::DriverKind::Dblib) => dblib::DblibConn::open(dsn).map(Conn::Dblib), + #[cfg(feature = "firebird")] + Some(driver::DriverKind::Firebird) => firebird::FirebirdConn::open(dsn).map(Conn::Firebird), + #[cfg(feature = "odbc")] + Some(driver::DriverKind::Odbc) => odbc::OdbcConn::open_odbc(dsn).map(Conn::Odbc), + #[cfg(feature = "informix")] + Some(driver::DriverKind::Informix) => { + odbc::OdbcConn::open_informix(dsn).map(Conn::Odbc) + } + #[cfg(feature = "ibm")] + Some(driver::DriverKind::Ibm) => odbc::OdbcConn::open_ibm(dsn).map(Conn::Odbc), + #[cfg(feature = "sqlsrv")] + Some(driver::DriverKind::Sqlsrv) => odbc::OdbcConn::open_sqlsrv(dsn).map(Conn::Odbc), + #[cfg(feature = "oci")] + Some(driver::DriverKind::Oci) => oci::OciConn::open(dsn).map(Conn::Oci), + Some(driver::DriverKind::Sqlite) => { + let path = dsn.strip_prefix(driver::DriverKind::Sqlite.dsn_prefix()).unwrap_or_default(); + sqlite::SqliteConn::open(path, sqlite_open_flags).map(Conn::Sqlite) + } + Some(driver::DriverKind::Pgsql) => pg::PgConn::open(dsn).map(Conn::Postgres), + Some(driver::DriverKind::Mysql) => my::MyConn::open( + dsn, + my_init_command, + my_ssl_config, + my_found_rows, + my_driver_config, ) + .map(Conn::Mysql), + None => Err("could not find driver".to_string()), } } /// Registers a newly opened connection and returns the public handle ID. fn register_conn(conn: Conn) -> i64 { let id = next_id(); - conns().lock().unwrap().insert(id, conn); + lock_recover(conns()).insert(id, conn); id } /// Opens a non-persistent connection and stores any failure message for the PDO /// constructor's `elephc_pdo_last_open_error()` call. -fn open_nonpersistent_dsn(dsn: &str) -> i64 { - match open_conn_for_dsn(dsn) { +fn open_nonpersistent_dsn( + dsn: &str, + sqlite_open_flags: i64, + my_init_command: &str, + my_ssl_config: &str, + my_found_rows: bool, + my_driver_config: &str, +) -> i64 { + match open_conn_for_dsn( + dsn, + sqlite_open_flags, + my_init_command, + my_ssl_config, + my_found_rows, + my_driver_config, + ) { Ok(conn) => register_conn(conn), Err(msg) => { - store_cstr(open_error_cell(), &msg); + store_open_failure(dsn, &msg); -1 } } } -/// Opens or reuses a process-local persistent connection for the full DSN. -fn open_persistent_dsn(dsn: &str) -> i64 { - if let Some(id) = persistent_conns().lock().unwrap().get(dsn).copied() { - if conns().lock().unwrap().contains_key(&id) { +/// Checks a cached persistent handle using the same driver split as php-src: +/// SQLite needs no probe, MySQL sends COM_PING, and PostgreSQL consults the live +/// client connection state maintained by its connection driver. +fn persistent_connection_is_live(conn_id: i64) -> bool { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(connection)) => connection.is_alive(), + #[cfg(feature = "dblib")] + Some(Conn::Dblib(connection)) => connection.is_alive(), + #[cfg(feature = "firebird")] + Some(Conn::Firebird(connection)) => connection.is_alive(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(connection)) => connection.is_alive(), + #[cfg(feature = "oci")] + Some(Conn::Oci(connection)) => connection.is_alive(), + Some(Conn::Sqlite(_)) => true, + Some(Conn::Mysql(connection)) => connection.is_alive(), + Some(Conn::Postgres(connection)) => !connection.is_closed(), + None => false, + } +} + +/// Evicts a dead persistent connection and every statement that still points to +/// it before a replacement handle is registered. +fn evict_persistent_connection(conn_id: i64) { + lock_recover(stmts()).retain(|_, statement| match statement { + #[cfg(feature = "cubrid")] + Stmt::Cubrid(statement) => statement.conn_id != conn_id, + #[cfg(feature = "dblib")] + Stmt::Dblib(statement) => statement.conn_id != conn_id, + #[cfg(feature = "firebird")] + Stmt::Firebird(statement) => statement.conn_id != conn_id, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Stmt::Odbc(statement) => statement.conn_id != conn_id, + #[cfg(feature = "oci")] + Stmt::Oci(statement) => statement.conn_id != conn_id, + Stmt::Sqlite(_) => true, + Stmt::Postgres(statement) => statement.conn_id != conn_id, + Stmt::Mysql(statement) => statement.conn_id != conn_id, + }); + lock_recover(conns()).remove(&conn_id); + lock_recover(persistent_ids()).remove(&conn_id); + lock_recover(persistent_owner_counts()).remove(&conn_id); +} + +/// Opens or reuses a process-local persistent connection for the `(dsn, +/// persistent_key)` pool key (F-CORE-16; `persistent_key` is `""` for the plain +/// boolean-persistent case — see [`persistent_conns`] for why the key string is part +/// of the key at all). +/// +/// `sqlite_open_flags`/`my_init_command`/`my_ssl_config`/`my_found_rows` are only +/// applied on a fresh open: they are not part of the pool key, so a later open +/// reusing an already-pooled connection does not re-apply a different +/// flags/init-command/capability request (matching how no other constructor option +/// retroactively affects a reused persistent handle either — and mirroring php-src, +/// whose hashkey is likewise built from the DSN and the persistent key alone). +fn open_persistent_dsn( + dsn: &str, + persistent_key: &str, + sqlite_open_flags: i64, + my_init_command: &str, + my_ssl_config: &str, + my_found_rows: bool, + my_driver_config: &str, +) -> i64 { + let _checkout = lock_recover(persistent_checkout_lock()); + let pool_key = (dsn.to_string(), persistent_key.to_string()); + if let Some(id) = lock_recover(persistent_conns()).get(&pool_key).copied() { + if persistent_connection_is_live(id) { + let mut owners = lock_recover(persistent_owner_counts()); + *owners.entry(id).or_insert(0) += 1; return id; } + evict_persistent_connection(id); + lock_recover(persistent_conns()).remove(&pool_key); } - match open_conn_for_dsn(dsn) { + match open_conn_for_dsn( + dsn, + sqlite_open_flags, + my_init_command, + my_ssl_config, + my_found_rows, + my_driver_config, + ) { Ok(conn) => { let id = register_conn(conn); - persistent_conns() - .lock() - .unwrap() - .insert(dsn.to_string(), id); - persistent_ids().lock().unwrap().insert(id); + lock_recover(persistent_conns()).insert(pool_key, id); + lock_recover(persistent_ids()).insert(id); + lock_recover(persistent_owner_counts()).insert(id, 1); id } Err(msg) => { - store_cstr(open_error_cell(), &msg); + store_open_failure(dsn, &msg); -1 } } } -/// Returns the bridge ABI version. Bumped when the C ABI shape changes. +/// Returns the bridge ABI version. Bumped when the C ABI shape changes. v7 adds +/// connection/statement SQLSTATE + statement error accessors, boolean/blob binds, +/// a busy-timeout setter, server version reporting, and a text-valued last-insert +/// id. v8 adds the PostgreSQL backend-pid and MySQL warning-count accessors that +/// back `Pdo\Pgsql::getPid()` / `Pdo\Mysql::getWarningCount()`. v9 adds the +/// PostgreSQL large-object create/unlink and COPY in/out accessors backing +/// `Pdo\Pgsql::lobCreate()` / `lobUnlink()` / `copyFrom*()` / `copyTo*()`. v10 adds +/// the SQLite column-decltype and load-extension accessors backing +/// `PDOStatement::getColumnMeta()`'s native type and `Pdo\Sqlite::loadExtension()`. +/// v11 adds the PostgreSQL LISTEN/NOTIFY poll backing `Pdo\Pgsql::getNotify()`. +/// v12 adds the whole-BLOB / whole-large-object read accessors backing +/// `Pdo\Sqlite::openBlob()` / `Pdo\Pgsql::lobOpen()`. v13 adds the SQLite +/// custom-collation registration +/// (`elephc_pdo_create_collation`) backing `Pdo\Sqlite::createCollation()`, whose +/// comparator descriptor and codegen adapter cross as two plain `ptr` arguments. +/// v14 adds the SQLite scalar user-function registration +/// (`elephc_pdo_create_function` + `elephc_pdo_udf_stash_bytes`) backing +/// `Pdo\Sqlite::createFunction()`, sharing the same descriptor/adapter `ptr` shape. +/// v15 adds the SQLite aggregate registration (`elephc_pdo_create_aggregate`) backing +/// `Pdo\Sqlite::createAggregate()`, crossing a step + finalize (descriptor, adapter) +/// pair with the per-group accumulator held in SQLite's aggregate context. +/// v16 adds the PostgreSQL NOTICE drain (`elephc_pdo_get_notice`) backing +/// `Pdo\Pgsql::setNoticeCallback()`, buffered via the connection's `notice_callback`. +/// v17 adds a `sqlite_open_flags` parameter to `elephc_pdo_open_persistent` backing +/// `Pdo\Sqlite::ATTR_OPEN_FLAGS` (P1-10; `0` = no override) and a +/// `sqlite3_stmt_readonly` accessor (`elephc_pdo_stmt_readonly`) backing +/// `PDOStatement::getAttribute(Pdo\Sqlite::ATTR_READONLY_STATEMENT)` (P2-16). +/// v18 adds a `my_init_command` parameter to `elephc_pdo_open_persistent` (P1-9; +/// empty string = none) — one SQL statement run by the MySQL/MariaDB server right +/// after authentication on every (re)connect, backing the minimal wiring for +/// `Pdo\Mysql::ATTR_INIT_COMMAND`; ignored for `sqlite:`/`pgsql:` DSNs. +/// v19 adds a `my_ssl_config` parameter to `elephc_pdo_open_persistent` (empty = +/// no TLS) — the prelude's packed `Pdo\Mysql::ATTR_SSL_*` options applied to the +/// MySQL/MariaDB connection's ring-backed rustls backend (enabled by default) — +/// and enables PostgreSQL TLS via the DSN's own `sslmode`/`sslrootcert` +/// keys through the default `tls` feature's rustls (ring) connector. No new extern +/// is added for pg (its TLS parameters ride the DSN). +/// v20 adds an explicit `len` parameter to `elephc_pdo_bind_text` (the value's +/// true byte length, replacing SQLite's strlen-based `-1` sentinel and pg/mysql's +/// NUL-terminated `cstr_arg` decode) so a bound string with an embedded NUL byte +/// binds in full instead of silently truncating at the first NUL (P0-A); it also +/// routes `PDO::PARAM_LOB` binds through the pre-existing `elephc_pdo_bind_blob` +/// from the prelude, which was implemented but never called. +/// v21 adds `elephc_pdo_no_backslash_escapes`, a live read of whether a `mysql:` +/// connection's session has `NO_BACKSLASH_ESCAPES` active in its `sql_mode`, +/// backing `PDO::quote()`'s MySQL branch (P1-f): under that mode backslash is a +/// literal character in a string literal, so the usual backslash-escaping is +/// unsafe (an escaped quote does not actually escape) and must fall back to +/// `''`-doubling only, matching mysqlnd's own behavior. +/// v22 adds `elephc_pdo_in_transaction`, a live transaction-state read backing +/// `PDO::inTransaction()` / `beginTransaction()`'s already-active guard (P1-g). +/// SQLite reads native autocommit; PostgreSQL/MySQL state is maintained from every +/// successful bridge-owned command because their client crates hide the protocol flag. +/// v23 adds `elephc_pdo_column_native_type` and `elephc_pdo_column_type_oid`, +/// which thread a `pgsql:` result column's `postgres::types::Type` (the server's +/// `pg_type.typname` and `PQftype` OID, resolved at prepare time) through to +/// `PDOStatement::getColumnMeta()` (P2-k). The prelude uses them to report the +/// real PostgreSQL `native_type` (`int4`/`bool`/`bytea`/…), the correct +/// `pdo_type` (BOOL→PARAM_BOOL, int-family→PARAM_INT, BYTEA→PARAM_LOB, else +/// PARAM_STR), and the `pgsql:oid` key, instead of the generic SQLite +/// storage-class metadata it emitted for every driver before. Both return a +/// neutral empty string / `0` for a non-PostgreSQL statement, so SQLite and +/// MySQL keep their existing storage-class metadata path unchanged. +/// v24 adds `elephc_pdo_blob_data_ptr`, which hands back a pointer to the whole +/// shared blob buffer so the prelude can bulk-copy a BLOB / large object with one +/// `ptr_read_string` instead of draining it a byte at a time through +/// `elephc_pdo_blob_byte` (kept as the fallback path), and +/// `elephc_pdo_set_extended_result_codes`, which calls +/// `sqlite3_extended_result_codes` for a `sqlite:` connection to back +/// `Pdo\Sqlite::ATTR_EXTENDED_RESULT_CODES` (F-SQLT-02). It REMOVES +/// `elephc_pdo_column_text` (F-QUAL-03): it was declared by the prelude but never +/// called, and it silently stripped embedded NUL bytes on the way out — every live +/// column read goes through the NUL-preserving +/// `elephc_pdo_column_data_len`/`elephc_pdo_column_data_ptr` pair instead. +/// v25 adds two trailing parameters to `elephc_pdo_open_persistent`. `my_found_rows` +/// (`0`/`1`) ORs `CLIENT_FOUND_ROWS` into a `mysql:` connection's negotiated +/// capabilities, backing `Pdo\Mysql::ATTR_FOUND_ROWS` (F-MY-06): it switches an +/// UPDATE's `rowCount()` from "rows actually CHANGED" to "rows MATCHED by the WHERE +/// clause", a capability that can only be selected in the connect handshake; it is +/// ignored for `sqlite:`/`pgsql:` DSNs. `persistent_key` is the caller's +/// `PDO::ATTR_PERSISTENT` string when that option was given as a non-numeric, +/// non-empty string (else `""`), and now forms the persistent pool key TOGETHER with +/// the DSN (F-CORE-16): php-src builds its persistent hashkey from both +/// (`pdo_dbh.c:389-404`), so two persistent connections to the same DSN under +/// different key strings are DISTINCT pooled entries rather than one wrongly shared +/// connection. It is ignored when `persistent` is `0`. +/// v26 adds the three remaining PostgreSQL column-metadata accessors — +/// `elephc_pdo_column_table_oid` (`PQftable`), `elephc_pdo_column_len` (`PQfsize`) +/// and `elephc_pdo_column_precision` (`PQfmod`) — which back `getColumnMeta`'s +/// `pgsql:table_oid`, `len` and `precision` keys (F-PG-01, F-PG-02). php-src emits +/// `pgsql:table_oid` UNCONDITIONALLY, `0` (`InvalidOid`, i.e. "not a plain table +/// column") included, so the prelude emits the key even for a `0`; `len` is the +/// TYPE's byte width (`int4` → 4, `timestamp` → 8) and `-1` for any varlena +/// (`text`, `varchar`, `numeric`, `bytea`, `json`, arrays), whose declared `n` +/// surfaces instead through `precision` as the RAW, undecoded `atttypmod` +/// (`VARCHAR(20)` → 24) — exactly as php-src stores them. All three return their +/// PostgreSQL-neutral value (`0` / `-1` / `-1`) for a non-`pgsql:` statement, so +/// SQLite and MySQL are untouched. v26 also extends `elephc_pdo_column_native_type` +/// to `mysql:` statements, which previously fell through to the empty string and +/// therefore reported the generic storage-class metadata: a MySQL column now +/// reports php-src's real `type_to_name_native` name +/// (`ext/pdo_mysql/mysql_statement.c:716-770`) — `LONG`, `VAR_STRING`, `BIT`, +/// `NEWDECIMAL`, `BLOB`, … — the stringified `MYSQL_TYPE_` suffix rather than the +/// friendlier SQL spelling (F-MY-08); a wire type php-src's own switch has no case +/// for still yields the empty string, matching its `default: return NULL`, which +/// makes php-src omit the key entirely. +/// v27 adds the `emulated` argument to `elephc_pdo_prepare`. MySQL uses the text +/// protocol when it is non-zero; PostgreSQL uses the simple-query protocol; SQLite +/// ignores it. This makes `PDO::ATTR_EMULATE_PREPARES`, MySQL direct-query mode and +/// `Pdo\Pgsql::ATTR_DISABLE_PREPARES` select a real protocol path rather than an +/// echo-only attribute. v28 adds `elephc_pdo_stmt_sent_sql`, exposing the most +/// recently rendered emulated SQL so `PDOStatement::debugDumpParams()` can print +/// php-src's `Sent SQL:` line without duplicating either driver's quoting logic. +/// v29 adds PHP 8.5 SQLite transaction, busy-statement, and explain-statement accessors. +/// v30 adds PHP 8.5 SQLite authorizer registration and nullable reset. v31 adds +/// live MySQL `PDO::ATTR_AUTOCOMMIT` mutation and state reads. v32 adds national +/// string binds for MySQL `PDO::ATTR_DEFAULT_STR_PARAM` / `PARAM_STR_NATL`. v33 +/// adds deferred SQLite authorizer callback error classification. v34 retains +/// every MySQL protocol result set and exposes `elephc_pdo_next_rowset`. v35 adds +/// `elephc_pdo_clear_callbacks`, which unregisters every SQLite native callback +/// before persistent PDO objects release their compiled callable descriptor roots. +/// v36 adds live client-version, server-information, and connection-status string +/// accessors for the generic PDO attributes. v37 adds PostgreSQL scroll-cursor +/// orientation stepping. v38 adds live MySQL table-name prefix configuration. +/// v39 adds PostgreSQL result-memory accounting. v40 adds binary-safe SQLite BLOB +/// and PostgreSQL large-object writeback for the seekable PDO stream wrappers. +/// v41 adds packed pdo_mysql connection options and buffered-query accessors. +/// v42 adds PostgreSQL connection/statement prefetch controls. +/// v43 adds source-table names and MySQL column flags. v44 adds version-aware +/// persistent-handle release so PHP 8.6 can reset PostgreSQL session state. v45 +/// adds bounded PostgreSQL large-object size/read/write operations. v46 adds the +/// equivalent bounded size/read/write operations for SQLite incremental BLOBs. +/// v47 enables PHP 8.5+'s demand-driven simple-query protocol per statement. +/// v48 exposes the compiled-driver registry and runtime `pdo.dsn.*` INI aliases. +/// v49 adds the optional PDO_DBLIB driver and its live attribute controls. v50 +/// adds the optional PDO_FIREBIRD backend and its driver-specific attributes. +/// v51 adds the optional PDO_ODBC backend through the system driver manager. +/// v52 adds PDO_OCI through Oracle Instant Client plus OCI attributes and metadata. +/// v53 adds OCI input/output bind registration and binary-safe output retrieval. +/// v54 exposes PDO_OCI constructor SQLSTATE and native ORA diagnostics. +/// v55 adds PDO_INFORMIX column scale/type/flag metadata and widens the generic +/// native-type/table-name accessors to the shared CLI backend. +/// v56 adds PDO_IBM 1.7.0 connection attributes and metadata, and completes the +/// common PDO core `name`/`len`/`precision` metadata for CLI-backed drivers. +/// v57 adds Microsoft PDO_SQLSRV 5.13.1 through Microsoft ODBC Driver 17/18, +/// including its statement attributes, connection information, datetime flag, +/// and sensitivity-classification metadata accessors. #[no_mangle] pub extern "C" fn elephc_pdo_version() -> i32 { - 6 + // Guarded like every other extern purely for uniformity — "every `#[no_mangle]` + // body opens with `ffi_guard`" is a grep-checkable invariant, and a constant + // body simply never reaches the fallback. + ffi_guard(57, || 57) } /// Returns a pointer to the lowercase PDO driver name for a connection /// (`"sqlite"`, `"pgsql"`, or `"mysql"`), or an empty string for an unknown /// handle. Backs `PDO::getAttribute(PDO::ATTR_DRIVER_NAME)`. Valid until the next -/// `elephc_pdo_driver_name`. +/// `elephc_pdo_driver_name`. A caught panic degrades to the same empty string. #[no_mangle] pub extern "C" fn elephc_pdo_driver_name(conn_id: i64) -> *const c_char { - let name = match conns().lock().unwrap().get(&conn_id) { - Some(Conn::Sqlite(_)) => "sqlite", - Some(Conn::Postgres(_)) => "pgsql", - Some(Conn::Mysql(_)) => "mysql", - None => "", - }; - store_cstr(drivername_cell(), name) + ffi_guard(static_cstr(b"\0"), || { + let name = lock_recover(conns()) + .get(&conn_id) + .map(Conn::driver_kind) + .map(driver::DriverKind::name) + .unwrap_or_default(); + store_cstr(drivername_cell(), name) + }) +} + +/// Returns the number of PDO drivers compiled into this bridge. +#[no_mangle] +pub extern "C" fn elephc_pdo_available_driver_count() -> i64 { + ffi_guard(0, || driver::AVAILABLE.len() as i64) +} + +/// Returns the lowercase name of the available driver at `index`, or an empty +/// string when `index` is outside the registry. The pointer remains valid for the +/// process lifetime because registry names are static string literals. +#[no_mangle] +pub extern "C" fn elephc_pdo_available_driver_name(index: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let Some(kind) = usize::try_from(index) + .ok() + .and_then(|index| driver::AVAILABLE.get(index)) + else { + return static_cstr(b"\0"); + }; + match kind { + #[cfg(feature = "cubrid")] + driver::DriverKind::Cubrid => static_cstr(b"cubrid\0"), + #[cfg(feature = "dblib")] + driver::DriverKind::Dblib => static_cstr(b"dblib\0"), + #[cfg(feature = "firebird")] + driver::DriverKind::Firebird => static_cstr(b"firebird\0"), + #[cfg(feature = "odbc")] + driver::DriverKind::Odbc => static_cstr(b"odbc\0"), + #[cfg(feature = "informix")] + driver::DriverKind::Informix => static_cstr(b"informix\0"), + #[cfg(feature = "ibm")] + driver::DriverKind::Ibm => static_cstr(b"ibm\0"), + #[cfg(feature = "sqlsrv")] + driver::DriverKind::Sqlsrv => static_cstr(b"sqlsrv\0"), + #[cfg(feature = "oci")] + driver::DriverKind::Oci => static_cstr(b"oci\0"), + driver::DriverKind::Mysql => static_cstr(b"mysql\0"), + driver::DriverKind::Pgsql => static_cstr(b"pgsql\0"), + driver::DriverKind::Sqlite => static_cstr(b"sqlite\0"), + } + }) +} + +/// Returns `1` when runtime PHP configuration defines `pdo.dsn.`, and `0` +/// otherwise. Alias names are case-sensitive like php-src's configuration table. +/// +/// # Safety +/// `name` must point to a NUL-terminated string valid for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_ini_dsn_defined(name: *const c_char) -> i64 { + ffi_guard(0, || { + cstr_arg(name) + .and_then(ini::lookup) + .map_or(0, |_| 1) + }) +} + +/// Returns the configured value of `pdo.dsn.`, or an empty string for an +/// absent/invalid name. Callers use `elephc_pdo_ini_dsn_defined()` to distinguish +/// an absent alias from a deliberately configured empty value. +/// +/// # Safety +/// `name` must point to a NUL-terminated string valid for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_ini_dsn_value(name: *const c_char) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let value = cstr_arg(name).and_then(ini::lookup).unwrap_or_default(); + store_cstr(dsn_alias_cell(), value) + }) } /// Opens a non-persistent database for a PDO DSN, dispatching on the driver /// prefix. Returns an `i64` connection handle, or `-1` on failure with the -/// message stashed for `elephc_pdo_last_open_error`. +/// message stashed for `elephc_pdo_last_open_error`. A caught panic degrades to the +/// same `-1` (with whatever message was last stashed) rather than aborting. /// /// # Safety /// `dsn` must point to a NUL-terminated string valid for the duration of the call. #[no_mangle] pub unsafe extern "C" fn elephc_pdo_open(dsn: *const c_char) -> i64 { - let Some(dsn) = cstr_arg(dsn) else { - store_cstr(open_error_cell(), "invalid DSN"); - return -1; - }; - open_nonpersistent_dsn(dsn) + ffi_guard(-1, || { + let Some(dsn) = cstr_arg(dsn) else { + store_open_failure("", "invalid DSN"); + return -1; + }; + open_nonpersistent_dsn(dsn, 0, "", "", false, "") + }) } /// Opens a database for a PDO DSN, reusing a process-local pooled connection when /// `persistent` is non-zero. Persistent handles stay registered until process -/// exit; `elephc_pdo_close` is a no-op for them. +/// exit; release only decrements their live-owner count. `sqlite_open_flags` (v17) is the +/// raw `sqlite3_open_v2` flags to open a `sqlite:` DSN with — `0` means "use the +/// default `READWRITE|CREATE`" — and is ignored for PostgreSQL/MySQL DSNs; it backs +/// `Pdo\Sqlite::ATTR_OPEN_FLAGS` (P1-10). `my_init_command` (v18) is a SQL +/// statement run right after authentication on a `mysql:` connection (empty = do +/// nothing), ignored for SQLite/PostgreSQL DSNs; it backs the minimal wiring for +/// `Pdo\Mysql::ATTR_INIT_COMMAND` (P1-9). `my_ssl_config` (v19) is the prelude's +/// packed `Pdo\Mysql::ATTR_SSL_*` options (`ca=…;cert=…;key=…;verify=0|1`, empty = +/// no TLS) applied to the `mysql:` connection's rustls backend; it is ignored for +/// SQLite/PostgreSQL DSNs (PostgreSQL carries its own `sslmode`/`sslrootcert` in +/// the DSN) and requires the default `mysql-tls` feature to take effect. +/// `my_found_rows` (v25) is `1` to OR `CLIENT_FOUND_ROWS` into a `mysql:` +/// connection's negotiated capabilities and `0` not to; it backs +/// `Pdo\Mysql::ATTR_FOUND_ROWS` (F-MY-06), which makes an UPDATE's `rowCount()` +/// report the rows its WHERE clause MATCHED instead of the rows it actually CHANGED, +/// and is ignored for SQLite/PostgreSQL DSNs. `persistent_key` (v25) is the caller's +/// `PDO::ATTR_PERSISTENT` string when that option was a non-numeric, non-empty string +/// (`""` otherwise, and for the plain boolean-persistent case); together with the DSN +/// it forms the persistent pool key (F-CORE-16), and it is ignored when `persistent` +/// is `0`. `my_driver_config` (v41) is the packed pdo_mysql connection-option +/// string carrying LOCAL INFILE controls, compression, IGNORE_SPACE, +/// multi-statements, buffering, CAPATH, cipher suites, and an authentication +/// public key. A caught panic degrades to the same `-1` failure sentinel as a failed open. /// /// # Safety -/// `dsn` must point to a NUL-terminated string valid for the duration of the call. +/// `dsn` and, when non-null, `my_init_command`/`my_ssl_config`/`persistent_key`/ +/// `my_driver_config` must each point to a NUL-terminated string valid for the +/// duration of the call. #[no_mangle] pub unsafe extern "C" fn elephc_pdo_open_persistent( dsn: *const c_char, persistent: i64, + sqlite_open_flags: i64, + my_init_command: *const c_char, + my_ssl_config: *const c_char, + my_found_rows: i64, + persistent_key: *const c_char, + my_driver_config: *const c_char, ) -> i64 { - let Some(dsn) = cstr_arg(dsn) else { - store_cstr(open_error_cell(), "invalid DSN"); - return -1; - }; - if persistent == 0 { - open_nonpersistent_dsn(dsn) - } else { - open_persistent_dsn(dsn) - } + ffi_guard(-1, || { + let Some(dsn) = cstr_arg(dsn) else { + store_open_failure("", "invalid DSN"); + return -1; + }; + let init_command = cstr_arg(my_init_command).unwrap_or(""); + let ssl_config = cstr_arg(my_ssl_config).unwrap_or(""); + let found_rows = my_found_rows != 0; + let driver_config = cstr_arg(my_driver_config).unwrap_or(""); + if persistent == 0 { + open_nonpersistent_dsn( + dsn, + sqlite_open_flags, + init_command, + ssl_config, + found_rows, + driver_config, + ) + } else { + // A null / non-UTF-8 key degrades to `""`, i.e. the plain + // boolean-persistent pool for this DSN — the pre-v25 behavior. + let key = cstr_arg(persistent_key).unwrap_or(""); + open_persistent_dsn( + dsn, + key, + sqlite_open_flags, + init_command, + ssl_config, + found_rows, + driver_config, + ) + } + }) } /// Returns a pointer to the message captured by the most recent failed -/// `elephc_pdo_open`. Valid until the next failed open. +/// `elephc_pdo_open`. Valid until the next failed open. A caught panic degrades to +/// an empty message. #[no_mangle] pub extern "C" fn elephc_pdo_last_open_error() -> *const c_char { - open_error_cell().lock().unwrap().as_ptr() + ffi_guard(static_cstr(b"\0"), || { + lock_recover(open_error_cell()).as_ptr() + }) } -/// Closes a connection (finalizing any SQLite statements still registered against -/// it) and removes it from the table. Unknown handles are ignored. +/// Returns the SQLSTATE captured for the most recent failed open, or an empty +/// string when that driver did not expose a constructor diagnostic. #[no_mangle] -pub extern "C" fn elephc_pdo_close(conn_id: i64) { - if persistent_ids().lock().unwrap().contains(&conn_id) { +pub extern "C" fn elephc_pdo_last_open_sqlstate() -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + lock_recover(open_sqlstate_cell()).as_ptr() + }) +} + +/// Returns the native driver code captured for the most recent failed open. +#[no_mangle] +pub extern "C" fn elephc_pdo_last_open_native_code() -> i64 { + ffi_guard(0, || open_native_code_cell().load(Ordering::Relaxed)) +} + +/// Releases one PDO owner of `conn_id`. Non-persistent connections are closed; +/// pooled handles remain cached. When the final owner selects PHP 8.6 reset +/// semantics, PostgreSQL performs its disconnect-equivalent session cleanup. +fn release_connection(conn_id: i64, reset_pgsql_session: bool) { + if lock_recover(persistent_ids()).contains(&conn_id) { + let became_idle = { + let mut owners = lock_recover(persistent_owner_counts()); + let count = owners.entry(conn_id).or_insert(0); + if *count == 0 { + false + } else { + *count -= 1; + *count == 0 + } + }; + if became_idle && reset_pgsql_session { + lock_recover(stmts()).retain(|_, statement| match statement { + Stmt::Postgres(statement) => statement.conn_id != conn_id, + _ => true, + }); + if let Some(Conn::Postgres(connection)) = lock_recover(conns()).get_mut(&conn_id) { + connection.discard_all(); + } + } return; } - // The SQLite db pointer of the connection being closed, so only *its* - // statements are finalized (statements from other open SQLite connections - // must be left alone). `None` when the connection is PostgreSQL or unknown. - let sqlite_db = match conns().lock().unwrap().get(&conn_id) { - Some(Conn::Sqlite(c)) => Some(c.db), - _ => None, - }; - // Finalize and drop the statements belonging to this connection so - // sqlite3_close does not fail with SQLITE_BUSY; PostgreSQL/MySQL statements - // live server-side and are dropped with the client. - let owned: Vec = stmts() - .lock() - .unwrap() - .iter() - .filter_map(|(k, s)| match s { - Stmt::Sqlite(st) if sqlite_db == Some(st.db) => Some(*k), - Stmt::Postgres(p) if p.conn_id == conn_id => Some(*k), - Stmt::Mysql(m) if m.conn_id == conn_id => Some(*k), + // The SQLite db pointer of the connection being closed, so only *its* + // statements are finalized (statements from other open SQLite connections + // must be left alone). `None` when the connection is PostgreSQL or unknown. + let sqlite_db = match lock_recover(conns()).get(&conn_id) { + Some(Conn::Sqlite(c)) => Some(c.db), _ => None, - }) - .collect(); - { - let mut guard = stmts().lock().unwrap(); - for k in owned { - if let Some(Stmt::Sqlite(s)) = guard.get(&k) { - s.finalize(); + }; + // Finalize and drop every statement belonging to this connection before its + // client handle. SQLite otherwise reports SQLITE_BUSY, while native clients + // such as CCI may wait for an outstanding request during disconnect. + let owned: Vec = lock_recover(stmts()) + .iter() + .filter_map(|(k, s)| match s { + Stmt::Sqlite(st) if sqlite_db == Some(st.db) => Some(*k), + Stmt::Postgres(p) if p.conn_id == conn_id => Some(*k), + Stmt::Mysql(m) if m.conn_id == conn_id => Some(*k), + #[cfg(feature = "cubrid")] + Stmt::Cubrid(c) if c.conn_id == conn_id => Some(*k), + #[cfg(feature = "dblib")] + Stmt::Dblib(d) if d.conn_id == conn_id => Some(*k), + #[cfg(feature = "firebird")] + Stmt::Firebird(f) if f.conn_id == conn_id => Some(*k), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Stmt::Odbc(o) if o.conn_id == conn_id => Some(*k), + #[cfg(feature = "oci")] + Stmt::Oci(o) if o.conn_id == conn_id => Some(*k), + _ => None, + }) + .collect(); + { + let mut guard = lock_recover(stmts()); + for k in owned { + if let Some(Stmt::Sqlite(s)) = guard.get(&k) { + s.finalize(); + } + guard.remove(&k); } - guard.remove(&k); } - } - if let Some(Conn::Sqlite(c)) = conns().lock().unwrap().get(&conn_id) { - c.close(); - } - conns().lock().unwrap().remove(&conn_id); + if let Some(Conn::Sqlite(c)) = lock_recover(conns()).get(&conn_id) { + c.close(); + } + lock_recover(conns()).remove(&conn_id); +} + +/// Closes a connection using PHP 8.0-8.5 persistent-session semantics. Unknown +/// handles and caught panics are ignored because destructors have no error channel. +#[no_mangle] +pub extern "C" fn elephc_pdo_close(conn_id: i64) { + ffi_guard((), || release_connection(conn_id, false)) +} + +/// Releases a connection with a version-selected PostgreSQL persistent reset. +/// `reset_pgsql_session != 0` is emitted only for the PHP 8.6 compatibility target. +#[no_mangle] +pub extern "C" fn elephc_pdo_release(conn_id: i64, reset_pgsql_session: i64) { + ffi_guard((), || { + release_connection(conn_id, reset_pgsql_session != 0) + }) } /// Runs one or more SQL statements with no result rows (`PDO::exec`). Returns the -/// number of rows changed, or `-1` on error. +/// number of rows changed, or `-1` on error — the same sentinel a caught panic +/// degrades to. /// /// # Safety /// `sql` must point to a NUL-terminated string valid for the duration of the call. #[no_mangle] pub unsafe extern "C" fn elephc_pdo_exec(conn_id: i64, sql: *const c_char) -> i64 { - let mut guard = conns().lock().unwrap(); - match guard.get_mut(&conn_id) { - Some(Conn::Sqlite(c)) => c.exec(sql), - Some(Conn::Postgres(c)) => match cstr_arg(sql) { - Some(s) => c.exec(s), - None => -1, - }, - Some(Conn::Mysql(c)) => match cstr_arg(sql) { - Some(s) => c.exec(s), - None => -1, - }, - None => -1, - } + ffi_guard(-1, || { + let sqlite_db = match lock_recover(conns()).get(&conn_id) { + Some(Conn::Sqlite(connection)) => Some(connection.db), + _ => None, + }; + if let Some(db) = sqlite_db { + return sqlite::SqliteConn::exec_on(db, sql); + } + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => match cstr_arg(sql) { + Some(sql) => c.exec(sql).unwrap_or(-1), + None => -1, + }, + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => match cstr_arg(sql) { + Some(sql) => c.execute(sql).map_or(-1, |_| c.changes), + None => -1, + }, + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => match cstr_arg(sql) { + Some(sql) => c.execute(sql, Vec::new()).map_or(-1, |_| c.changes), + None => -1, + }, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => match cstr_arg(sql) { + Some(sql) => c.exec(sql), + None => -1, + }, + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => match cstr_arg(sql) { + Some(sql) => c.exec(sql), + None => -1, + }, + Some(Conn::Postgres(c)) => match cstr_arg(sql) { + Some(s) => c.exec(s), + None => -1, + }, + Some(Conn::Mysql(c)) => match cstr_arg(sql) { + Some(s) => c.exec(s), + None => -1, + }, + Some(Conn::Sqlite(_)) | None => -1, + } + }) } /// Returns the id of the most recent INSERT: the SQLite rowid, or for PostgreSQL /// `currval(name)` when a non-empty sequence name is given else `lastval()`. +/// Unknown handles — and a caught panic — report `0`. /// /// # Safety /// `name`, when non-null, must point to a NUL-terminated string valid for the call. #[no_mangle] pub unsafe extern "C" fn elephc_pdo_last_insert_id(conn_id: i64, name: *const c_char) -> i64 { - let mut guard = conns().lock().unwrap(); - match guard.get_mut(&conn_id) { - Some(Conn::Sqlite(c)) => c.last_insert_id(), - Some(Conn::Postgres(c)) => c.last_insert_id(cstr_arg(name)), - Some(Conn::Mysql(c)) => c.last_insert_id(cstr_arg(name)), - None => 0, - } + ffi_guard(0, || { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.last_insert_id().parse().unwrap_or(0), + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c + .execute("SELECT @@IDENTITY") + .ok() + .and_then(|sets| sets.into_iter().next()) + .and_then(|set| set.rows.into_iter().next()) + .and_then(|row| row.into_iter().next()) + .and_then(|cell| match cell { + dblib::DblibCell::Int(value) => Some(value), + dblib::DblibCell::Float(value) => Some(value as i64), + dblib::DblibCell::Bytes(value, _) => String::from_utf8(value).ok()?.parse().ok(), + dblib::DblibCell::Null => None, + }) + .unwrap_or(0), + #[cfg(feature = "firebird")] + Some(Conn::Firebird(_)) => 0, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.last_insert_id(cstr_arg(name)).parse().unwrap_or(0), + #[cfg(feature = "oci")] + Some(Conn::Oci(_)) => 0, + Some(Conn::Sqlite(c)) => c.last_insert_id(), + Some(Conn::Postgres(c)) => c.last_insert_id(cstr_arg(name)), + Some(Conn::Mysql(c)) => c.last_insert_id(cstr_arg(name)), + None => 0, + } + }) +} + +/// Like `elephc_pdo_last_insert_id`, but returns a pointer to the id rendered as +/// text: PostgreSQL sequence values are not always safe to round-trip as `i64` +/// (a caller-chosen sequence can be any integer type), so text avoids a lossy or +/// failing numeric bridge; likewise (P2-2) a MySQL `BIGINT UNSIGNED` +/// AUTO_INCREMENT id can exceed `i64::MAX`. Empty string on an unknown handle or +/// error — the same answer a caught panic degrades to. Valid until the next +/// `elephc_pdo_last_insert_id_text`. +/// +/// # Safety +/// `name`, when non-null, must point to a NUL-terminated string valid for the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_last_insert_id_text( + conn_id: i64, + name: *const c_char, +) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let text = { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.last_insert_id(), + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c + .execute("SELECT @@IDENTITY") + .ok() + .and_then(|sets| sets.into_iter().next()) + .and_then(|set| set.rows.into_iter().next()) + .and_then(|row| row.into_iter().next()) + .map(|cell| match cell { + dblib::DblibCell::Int(value) => value.to_string(), + dblib::DblibCell::Float(value) => value.to_string(), + dblib::DblibCell::Bytes(value, _) => String::from_utf8_lossy(&value).into_owned(), + dblib::DblibCell::Null => String::new(), + }) + .unwrap_or_default(), + #[cfg(feature = "firebird")] + Some(Conn::Firebird(_)) => String::new(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.last_insert_id(cstr_arg(name)), + #[cfg(feature = "oci")] + Some(Conn::Oci(_)) => String::new(), + Some(Conn::Sqlite(c)) => c.last_insert_id().to_string(), + Some(Conn::Postgres(c)) => c.last_insert_id_text(cstr_arg(name)), + Some(Conn::Mysql(c)) => c.last_insert_id_text(cstr_arg(name)), + None => String::new(), + } + }; + store_cstr(last_insert_id_text_cell(), &text) + }) } -/// Returns the number of rows changed by the most recent statement. +/// Returns the number of rows changed by the most recent statement. Unknown handles +/// — and a caught panic — report `0`. #[no_mangle] pub extern "C" fn elephc_pdo_changes(conn_id: i64) -> i64 { - let guard = conns().lock().unwrap(); - match guard.get(&conn_id) { - Some(Conn::Sqlite(c)) => c.changes(), - Some(Conn::Postgres(c)) => c.changes, - Some(Conn::Mysql(c)) => c.changes, - None => 0, - } + ffi_guard(0, || { + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.changes, + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c.changes, + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.changes, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.changes, + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.changes, + Some(Conn::Sqlite(c)) => c.changes(), + Some(Conn::Postgres(c)) => c.changes, + Some(Conn::Mysql(c)) => c.changes, + None => 0, + } + }) } -/// Begins a transaction (`PDO::beginTransaction`). Returns `1`/`0`. +/// Begins a transaction (`PDO::beginTransaction`). Returns `1`/`0`; a caught panic +/// reports the `0` failure sentinel. #[no_mangle] pub extern "C" fn elephc_pdo_begin(conn_id: i64) -> i64 { - let mut guard = conns().lock().unwrap(); - match guard.get_mut(&conn_id) { - Some(Conn::Sqlite(c)) => c.exec_simple(b"BEGIN"), - Some(Conn::Postgres(c)) => c.exec_simple("BEGIN"), - Some(Conn::Mysql(c)) => c.exec_simple("BEGIN"), - None => 0, - } + ffi_guard(0, || { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.begin() as i64, + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c.transaction("BEGIN TRANSACTION", true) as i64, + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.begin() as i64, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.begin() as i64, + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.begin() as i64, + Some(Conn::Sqlite(c)) => c.begin_transaction(), + Some(Conn::Postgres(c)) => c.exec_simple("BEGIN"), + Some(Conn::Mysql(c)) => c.exec_simple("BEGIN"), + None => 0, + } + }) } -/// Commits the active transaction (`PDO::commit`). Returns `1`/`0`. +/// Commits the active transaction (`PDO::commit`). Returns `1`/`0`; a caught panic +/// reports the `0` failure sentinel. #[no_mangle] pub extern "C" fn elephc_pdo_commit(conn_id: i64) -> i64 { - let mut guard = conns().lock().unwrap(); - match guard.get_mut(&conn_id) { - Some(Conn::Sqlite(c)) => c.exec_simple(b"COMMIT"), - Some(Conn::Postgres(c)) => c.exec_simple("COMMIT"), - Some(Conn::Mysql(c)) => c.exec_simple("COMMIT"), - None => 0, - } + ffi_guard(0, || { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.commit() as i64, + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c.transaction("COMMIT TRANSACTION", false) as i64, + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.commit() as i64, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.commit() as i64, + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.commit() as i64, + Some(Conn::Sqlite(c)) => c.exec_simple(b"COMMIT"), + Some(Conn::Postgres(c)) => c.exec_simple("COMMIT"), + Some(Conn::Mysql(c)) => c.exec_simple("COMMIT"), + None => 0, + } + }) } -/// Rolls back the active transaction (`PDO::rollBack`). Returns `1`/`0`. +/// Rolls back the active transaction (`PDO::rollBack`). Returns `1`/`0`; a caught +/// panic reports the `0` failure sentinel. #[no_mangle] pub extern "C" fn elephc_pdo_rollback(conn_id: i64) -> i64 { - let mut guard = conns().lock().unwrap(); - match guard.get_mut(&conn_id) { - Some(Conn::Sqlite(c)) => c.exec_simple(b"ROLLBACK"), - Some(Conn::Postgres(c)) => c.exec_simple("ROLLBACK"), - Some(Conn::Mysql(c)) => c.exec_simple("ROLLBACK"), - None => 0, - } -} - -/// Returns the driver's result code for the connection's last operation. -#[no_mangle] -pub extern "C" fn elephc_pdo_errcode(conn_id: i64) -> i64 { - let guard = conns().lock().unwrap(); - match guard.get(&conn_id) { - Some(Conn::Sqlite(c)) => c.errcode(), - Some(Conn::Postgres(c)) => c.errcode, - Some(Conn::Mysql(c)) => c.errcode, - None => -1, - } + ffi_guard(0, || { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.rollback() as i64, + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c.transaction("ROLLBACK TRANSACTION", false) as i64, + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.rollback() as i64, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.rollback() as i64, + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.rollback() as i64, + Some(Conn::Sqlite(c)) => c.exec_simple(b"ROLLBACK"), + Some(Conn::Postgres(c)) => c.exec_simple("ROLLBACK"), + Some(Conn::Mysql(c)) => c.exec_simple("ROLLBACK"), + None => 0, + } + }) } -/// Returns a pointer to the connection's current error message. Valid until the -/// next `elephc_pdo_errmsg`. +/// Returns the connection's LIVE transaction state (P1-g), so a transaction +/// started via a raw `exec("BEGIN")` — bypassing `PDO::beginTransaction()` — is +/// still visible to `PDO::inTransaction()` and to `beginTransaction()`'s +/// already-active guard. `1` = definitely in a transaction, `0` = definitely +/// not; `-1` = unknown because the handle is unrecognized — the prelude falls back to its own `$inTxn` flag in +/// that case. SQLite reads `sqlite3_get_autocommit` live. MySQL/MariaDB and +/// PostgreSQL expose bridge-maintained state updated after every successful command, +/// including raw `BEGIN`/`COMMIT`/`ROLLBACK` sent through `PDO::exec`. +/// A caught panic degrades to that same `-1` ("unknown"), which the prelude +/// already knows how to fall back from. #[no_mangle] -pub extern "C" fn elephc_pdo_errmsg(conn_id: i64) -> *const c_char { - let msg = { - let guard = conns().lock().unwrap(); +pub extern "C" fn elephc_pdo_in_transaction(conn_id: i64) -> i64 { + ffi_guard(-1, || { + let guard = lock_recover(conns()); match guard.get(&conn_id) { - Some(Conn::Sqlite(c)) => c.errmsg(), - Some(Conn::Postgres(c)) => c.errmsg.clone(), - Some(Conn::Mysql(c)) => c.errmsg.clone(), - None => String::new(), + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.in_transaction as i64, + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c.in_transaction as i64, + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.in_transaction as i64, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.in_transaction as i64, + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.in_transaction as i64, + Some(Conn::Sqlite(c)) => c.in_transaction(), + Some(Conn::Postgres(c)) => c.in_transaction as i64, + Some(Conn::Mysql(c)) => c.in_transaction as i64, + None => -1, } - }; - store_cstr(errmsg_cell(), &msg) + }) } -/// Prepares a statement (`PDO::prepare` / `PDO::query`) and returns an `i64` -/// statement handle, or `-1` on a compile error. -/// -/// # Safety -/// `sql` must point to a NUL-terminated string valid for the duration of the call. +/// Sets MySQL session autocommit and returns `1` on success. SQLite and +/// PostgreSQL do not expose this attribute through their php-src driver hooks, +/// so non-MySQL or unknown handles return `0`. #[no_mangle] -pub unsafe extern "C" fn elephc_pdo_prepare(conn_id: i64, sql: *const c_char) -> i64 { - let prepared: Result = { - let mut guard = conns().lock().unwrap(); - match guard.get_mut(&conn_id) { - Some(Conn::Sqlite(c)) => c.prepare(sql).map(Stmt::Sqlite), - Some(Conn::Postgres(c)) => match cstr_arg(sql) { - Some(s) => match c.prepare(s) { - Ok(mut st) => { - st.conn_id = conn_id; - Ok(Stmt::Postgres(st)) - } - Err(_) => Err(()), - }, - None => Err(()), - }, - Some(Conn::Mysql(c)) => match cstr_arg(sql) { - Some(s) => match c.prepare(s) { - Ok(mut st) => { - st.conn_id = conn_id; - Ok(Stmt::Mysql(st)) - } - Err(_) => Err(()), - }, - None => Err(()), - }, - None => Err(()), - } - }; - match prepared { - Ok(stmt) => { - let id = next_id(); - stmts().lock().unwrap().insert(id, stmt); - id - } - Err(()) => -1, - } +pub extern "C" fn elephc_pdo_set_autocommit(conn_id: i64, enabled: i64) -> i64 { + ffi_guard(0, || match lock_recover(conns()).get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.set_autocommit(enabled != 0) as i64, + Some(Conn::Mysql(c)) => c.set_autocommit(enabled != 0), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.set_attribute(0, enabled) as i64, + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.set_attribute_int(0, enabled) as i64, + _ => 0, + }) } -/// Resolves a named placeholder to its 1-based bind index, or `0` when unknown. -/// -/// # Safety -/// `name` must point to a NUL-terminated string valid for the duration of the call. +/// Returns MySQL's current session autocommit state, or `-1` for another +/// driver/unknown handle so the prelude can route unsupported attributes normally. #[no_mangle] -pub unsafe extern "C" fn elephc_pdo_bind_parameter_index(stmt_id: i64, name: *const c_char) -> i64 { - let guard = stmts().lock().unwrap(); - let Some(name) = cstr_arg(name) else { - return 0; - }; - match guard.get(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.bind_parameter_index(name), - Some(Stmt::Postgres(s)) => s.bind_parameter_index(name), - Some(Stmt::Mysql(s)) => s.bind_parameter_index(name), - None => 0, - } +pub extern "C" fn elephc_pdo_autocommit(conn_id: i64) -> i64 { + ffi_guard(-1, || match lock_recover(conns()).get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.attribute(0).unwrap_or(-1), + Some(Conn::Mysql(c)) => c.autocommit as i64, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.attribute(0).unwrap_or(-1), + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.attribute_int(0).unwrap_or(-1), + _ => -1, + }) } -/// Binds an integer to the 1-based placeholder `idx`. Returns `1`/`0`. +/// Enables or disables MySQL `PDO::ATTR_FETCH_TABLE_NAMES`; returns `1` for a +/// MySQL handle and `0` for another driver or an unknown handle. #[no_mangle] -pub extern "C" fn elephc_pdo_bind_int(stmt_id: i64, idx: i64, val: i64) -> i64 { - let mut guard = stmts().lock().unwrap(); - match guard.get_mut(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.bind_int(idx, val), - Some(Stmt::Postgres(s)) => s.bind(idx, pg::Bind::Int(val)), - Some(Stmt::Mysql(s)) => s.bind(idx, my::Bind::Int(val)), - None => 0, - } +pub extern "C" fn elephc_pdo_set_fetch_table_names(conn_id: i64, enabled: i64) -> i64 { + ffi_guard(0, || match lock_recover(conns()).get_mut(&conn_id) { + Some(Conn::Mysql(connection)) => { + connection.set_fetch_table_names(enabled != 0); + 1 + } + _ => 0, + }) } -/// Binds a double to the 1-based placeholder `idx`. Returns `1`/`0`. +/// Returns MySQL's current table-name prefix setting, or `-1` for another driver +/// or an unknown handle. #[no_mangle] -pub extern "C" fn elephc_pdo_bind_double(stmt_id: i64, idx: i64, val: f64) -> i64 { - let mut guard = stmts().lock().unwrap(); - match guard.get_mut(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.bind_double(idx, val), - Some(Stmt::Postgres(s)) => s.bind(idx, pg::Bind::Float(val)), - Some(Stmt::Mysql(s)) => s.bind(idx, my::Bind::Float(val)), - None => 0, - } +pub extern "C" fn elephc_pdo_fetch_table_names(conn_id: i64) -> i64 { + ffi_guard(-1, || match lock_recover(conns()).get(&conn_id) { + Some(Conn::Mysql(connection)) => connection.fetch_table_names as i64, + _ => -1, + }) +} + +/// Sets MySQL's default buffered-query mode for subsequently prepared +/// statements, returning 1 for a MySQL handle and 0 otherwise. +#[no_mangle] +pub extern "C" fn elephc_pdo_set_buffered_query(conn_id: i64, enabled: i64) -> i64 { + ffi_guard(0, || match lock_recover(conns()).get_mut(&conn_id) { + Some(Conn::Mysql(connection)) => connection.set_buffered_query(enabled != 0), + _ => 0, + }) +} + +/// Returns MySQL's current `ATTR_USE_BUFFERED_QUERY` default, or -1 when the +/// connection is unknown or belongs to another driver. +#[no_mangle] +pub extern "C" fn elephc_pdo_buffered_query(conn_id: i64) -> i64 { + ffi_guard(-1, || match lock_recover(conns()).get(&conn_id) { + Some(Conn::Mysql(connection)) => connection.buffered_query(), + _ => -1, + }) +} + +/// Sets PostgreSQL's default `PDO::ATTR_PREFETCH` mode for subsequently +/// prepared statements, returning 1 for a PostgreSQL handle and 0 otherwise. +#[no_mangle] +pub extern "C" fn elephc_pdo_set_prefetch(conn_id: i64, enabled: i64) -> i64 { + ffi_guard(0, || match lock_recover(conns()).get_mut(&conn_id) { + Some(Conn::Postgres(connection)) => connection.set_prefetch(enabled != 0), + #[cfg(feature = "oci")] + Some(Conn::Oci(connection)) => connection.set_attribute_int(1, enabled) as i64, + _ => 0, + }) +} + +/// Overrides one unexecuted PostgreSQL statement's prepare-time prefetch mode. +#[no_mangle] +pub extern "C" fn elephc_pdo_stmt_set_prefetch(stmt_id: i64, enabled: i64) -> i64 { + ffi_guard(0, || match lock_recover(stmts()).get_mut(&stmt_id) { + Some(Stmt::Postgres(statement)) => statement.set_prefetch(enabled != 0), + #[cfg(feature = "oci")] + Some(Stmt::Oci(statement)) => statement.set_prefetch(enabled), + _ => 0, + }) +} + +/// Enables lazy simple-protocol consumption for a PostgreSQL statement compiled +/// against PHP 8.5 or newer. Other drivers and already-executed statements reject it. +#[no_mangle] +pub extern "C" fn elephc_pdo_stmt_enable_simple_streaming(stmt_id: i64) -> i64 { + ffi_guard(0, || match lock_recover(stmts()).get_mut(&stmt_id) { + Some(Stmt::Postgres(statement)) => statement.enable_simple_streaming(), + _ => 0, + }) +} + +/// Returns the driver's result code for the connection's last operation. Unknown +/// handles — and a caught panic — report `-1`. +#[no_mangle] +pub extern "C" fn elephc_pdo_errcode(conn_id: i64) -> i64 { + ffi_guard(-1, || { + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.errcode(), + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c.errcode(), + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.errcode(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.errcode(), + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.errcode(), + Some(Conn::Sqlite(c)) => c.errcode(), + Some(Conn::Postgres(c)) => c.errcode, + Some(Conn::Mysql(c)) => c.errcode, + None => -1, + } + }) +} + +/// Returns a pointer to the connection's current error message. Valid until the +/// next `elephc_pdo_errmsg`. A caught panic degrades to an empty message. +#[no_mangle] +pub extern "C" fn elephc_pdo_errmsg(conn_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let msg = { + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.errmsg().to_string(), + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c.errmsg().to_string(), + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.errmsg().to_string(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.errmsg().to_string(), + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.errmsg().to_string(), + Some(Conn::Sqlite(c)) => c.errmsg(), + Some(Conn::Postgres(c)) => c.errmsg.clone(), + Some(Conn::Mysql(c)) => c.errmsg.clone(), + None => String::new(), + } + }; + store_cstr(errmsg_cell(), &msg) + }) +} + +/// Returns a pointer to the 5-char SQLSTATE for the connection's last operation +/// (`"00000"` on success). Unknown handles also report `"00000"` (no operation +/// has been recorded for them), and so does a caught panic — the call that panicked +/// has already reported its own `-1`/`0` failure, which is what the prelude raises +/// on. Valid until the next `elephc_pdo_sqlstate`. +#[no_mangle] +pub extern "C" fn elephc_pdo_sqlstate(conn_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"00000\0"), || { + let state = { + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.sqlstate().to_string(), + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c.sqlstate().to_string(), + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.sqlstate().to_string(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.sqlstate().to_string(), + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.sqlstate().to_string(), + Some(Conn::Sqlite(c)) => c.sqlstate(), + Some(Conn::Postgres(c)) => c.sqlstate.clone(), + Some(Conn::Mysql(c)) => c.sqlstate.clone(), + None => "00000".to_string(), + } + }; + store_cstr(sqlstate_cell(), &state) + }) +} + +/// Sets the busy-wait timeout (in milliseconds) for lock contention: SQLite calls +/// `sqlite3_busy_timeout`; PostgreSQL/MySQL have no equivalent client-side knob +/// for this bridge's one-statement-at-a-time connections, so they no-op and +/// report success. Returns `1`/`0`; a caught panic reports the `0` failure sentinel. +#[no_mangle] +pub extern "C" fn elephc_pdo_set_busy_timeout(conn_id: i64, ms: i64) -> i64 { + ffi_guard(0, || { + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(_)) => 1, + #[cfg(feature = "dblib")] + Some(Conn::Dblib(_)) => 1, + #[cfg(feature = "firebird")] + Some(Conn::Firebird(_)) => 1, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(_)) => 1, + #[cfg(feature = "oci")] + Some(Conn::Oci(_)) => 1, + Some(Conn::Sqlite(c)) => c.set_busy_timeout(ms), + Some(Conn::Postgres(_)) => 1, + Some(Conn::Mysql(_)) => 1, + None => 0, + } + }) +} + +/// Applies one writable PDO_CUBRID connection attribute through CCI. +#[no_mangle] +pub extern "C" fn elephc_pdo_cubrid_set_attribute( + conn_id: i64, + attribute: i64, + value: i64, +) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "cubrid")] + if let Some(Conn::Cubrid(connection)) = lock_recover(conns()).get_mut(&conn_id) { + return connection.set_attribute(attribute, value) as i64; + } + let _ = (conn_id, attribute, value); + 0 + }) } -/// Binds a text value to the 1-based placeholder `idx`. A null pointer binds SQL -/// NULL. Returns `1`/`0`. +/// Reads one PDO_CUBRID connection attribute, returning `-1` when unavailable. +#[no_mangle] +pub extern "C" fn elephc_pdo_cubrid_attribute(conn_id: i64, attribute: i64) -> i64 { + ffi_guard(-1, || { + #[cfg(feature = "cubrid")] + if let Some(Conn::Cubrid(connection)) = lock_recover(conns()).get_mut(&conn_id) { + return connection.attribute(attribute).unwrap_or(-1); + } + let _ = (conn_id, attribute); + -1 + }) +} + +/// Escapes exact bytes with PDO_CUBRID's native CCI quoter into the shared blob cell. /// /// # Safety -/// `val`, when non-null, must point to a NUL-terminated string valid for the call. -#[no_mangle] -pub unsafe extern "C" fn elephc_pdo_bind_text(stmt_id: i64, idx: i64, val: *const c_char) -> i64 { - let mut guard = stmts().lock().unwrap(); - match guard.get_mut(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.bind_text(idx, val), - Some(Stmt::Postgres(s)) => { - let bind = match cstr_arg(val) { - Some(t) => pg::Bind::Text(t.to_string()), - None => pg::Bind::Null, +/// `data` must expose at least `len` readable bytes and may only be null when `len` is zero. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_cubrid_quote( + conn_id: i64, + data: *const c_char, + len: i64, +) -> i64 { + ffi_guard(-1, || { + #[cfg(feature = "cubrid")] + { + let input = bytes_arg(data, len); + let mut connections = lock_recover(conns()); + let Some(Conn::Cubrid(connection)) = connections.get_mut(&conn_id) else { + return -1; }; - s.bind(idx, bind) + let output = match connection.quote(&input) { + Ok(output) => output, + Err(_) => return -1, + }; + let length = output.len() as i64; + *lock_recover(blob_cell()) = output; + return length; + } + #[cfg(not(feature = "cubrid"))] + { + let _ = (conn_id, data, len); + -1 + } + }) +} + +/// Stores one PDO_CUBRID named-type or collection bind for the next execution. +/// +/// # Safety +/// `data` must expose `len` readable bytes, and `type_name` must be a valid +/// NUL-terminated string for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_cubrid_bind_typed( + stmt_id: i64, + index: i64, + data: *const c_char, + len: i64, + type_name: *const c_char, + is_set: i64, + pdo_type: i64, +) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "cubrid")] + if let Some(Stmt::Cubrid(statement)) = lock_recover(stmts()).get_mut(&stmt_id) { + return statement.bind_typed( + index, + bytes_arg(data, len), + &cstr_arg(type_name).unwrap_or_default(), + is_set != 0, + pdo_type, + ) as i64; } - Some(Stmt::Mysql(s)) => { - let bind = match cstr_arg(val) { - Some(t) => my::Bind::Text(t.to_string()), - None => my::Bind::Null, + let _ = (stmt_id, index, data, len, type_name, is_set, pdo_type); + 0 + }) +} + +/// Creates an executed PDO_CUBRID schema-information statement and returns its handle. +/// +/// # Safety +/// Non-null `class_name` and `attribute_name` pointers must reference valid +/// NUL-terminated strings for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_cubrid_schema( + conn_id: i64, + schema_type: i64, + class_name: *const c_char, + attribute_name: *const c_char, +) -> i64 { + ffi_guard(-1, || { + #[cfg(feature = "cubrid")] + { + let class_name = cstr_arg(class_name).unwrap_or_default(); + let attribute_name = cstr_arg(attribute_name).unwrap_or_default(); + let mut connections = lock_recover(conns()); + let Some(Conn::Cubrid(connection)) = connections.get_mut(&conn_id) else { + return -1; }; - s.bind(idx, bind) + let statement = match cubrid::CubridStmt::schema( + connection, + conn_id, + schema_type, + class_name, + attribute_name, + ) { + Ok(statement) => statement, + Err(_) => return -1, + }; + let id = next_id(); + lock_recover(stmts()).insert(id, Stmt::Cubrid(statement)); + return id; } - None => 0, - } + #[cfg(not(feature = "cubrid"))] + { + let _ = (conn_id, schema_type, class_name, attribute_name); + -1 + } + }) } -/// Binds SQL NULL to the 1-based placeholder `idx`. Returns `1`/`0`. +/// Applies a writable PDO_DBLIB driver attribute. Returns `1` when DBLIB accepts +/// it and `0` for a read-only/unknown attribute, another driver, or a caught panic. #[no_mangle] -pub extern "C" fn elephc_pdo_bind_null(stmt_id: i64, idx: i64) -> i64 { - let mut guard = stmts().lock().unwrap(); - match guard.get_mut(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.bind_null(idx), - Some(Stmt::Postgres(s)) => s.bind(idx, pg::Bind::Null), - Some(Stmt::Mysql(s)) => s.bind(idx, my::Bind::Null), - None => 0, - } +pub extern "C" fn elephc_pdo_dblib_set_attribute( + conn_id: i64, + attribute: i64, + value: i64, +) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "dblib")] + if let Some(Conn::Dblib(connection)) = lock_recover(conns()).get_mut(&conn_id) { + return connection.set_attribute(attribute, value) as i64; + } + let _ = (conn_id, attribute, value); + 0 + }) } -/// Resets a statement, keeping its parameter bindings. Returns `1`/`0`. +/// Reads a boolean PDO_DBLIB driver attribute. Returns `0`/`1`, or `-1` when +/// the attribute is not readable, the handle uses another driver, or a panic occurs. #[no_mangle] -pub extern "C" fn elephc_pdo_reset(stmt_id: i64) -> i64 { - let mut guard = stmts().lock().unwrap(); - match guard.get_mut(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.reset(), - Some(Stmt::Postgres(s)) => s.reset(), - Some(Stmt::Mysql(s)) => s.reset(), - None => 0, - } +pub extern "C" fn elephc_pdo_dblib_attribute_bool(conn_id: i64, attribute: i64) -> i64 { + ffi_guard(-1, || { + #[cfg(feature = "dblib")] + if let Some(Conn::Dblib(connection)) = lock_recover(conns()).get(&conn_id) { + return connection.attribute_bool(attribute).map_or(-1, i64::from); + } + let _ = (conn_id, attribute); + -1 + }) } -/// Clears all parameter bindings on a statement. Returns `1`/`0`. +/// Returns PDO_DBLIB's operating-system error code for connection errorInfo. #[no_mangle] -pub extern "C" fn elephc_pdo_clear_bindings(stmt_id: i64) -> i64 { - let mut guard = stmts().lock().unwrap(); - match guard.get_mut(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.clear_bindings(), - Some(Stmt::Postgres(s)) => s.clear_bindings(), - Some(Stmt::Mysql(s)) => s.clear_bindings(), - None => 0, - } +pub extern "C" fn elephc_pdo_dblib_os_errcode(conn_id: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "dblib")] + if let Some(Conn::Dblib(connection)) = lock_recover(conns()).get(&conn_id) { + return connection.os_errcode(); + } + let _ = conn_id; + 0 + }) } -/// Advances the statement one row: `1` for a row, `0` when exhausted, `-1` on -/// error. +/// Returns PDO_DBLIB's error severity for connection errorInfo. #[no_mangle] -pub extern "C" fn elephc_pdo_step(stmt_id: i64) -> i64 { - let mut sguard = stmts().lock().unwrap(); - match sguard.get_mut(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.step(), - Some(Stmt::Postgres(s)) => { - let conn_id = s.conn_id; - let mut cguard = conns().lock().unwrap(); - match cguard.get_mut(&conn_id) { - Some(Conn::Postgres(c)) => s.step(c), - _ => -1, +pub extern "C" fn elephc_pdo_dblib_severity(conn_id: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "dblib")] + if let Some(Conn::Dblib(connection)) = lock_recover(conns()).get(&conn_id) { + return connection.severity(); + } + let _ = conn_id; + 0 + }) +} + +/// Returns PDO_DBLIB's operating-system diagnostic for a connection. The pointer +/// remains valid until the next call; unsupported or unknown handles return empty. +#[no_mangle] +pub extern "C" fn elephc_pdo_dblib_os_errmsg(conn_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let message = { + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + #[cfg(feature = "dblib")] + Some(Conn::Dblib(connection)) => connection.os_errmsg().to_string(), + _ => String::new(), } + }; + store_cstr(dblib_os_errmsg_cell(), &message) + }) +} + +/// Applies an integer or boolean PDO_FIREBIRD connection attribute. +#[no_mangle] +pub extern "C" fn elephc_pdo_firebird_set_attribute_int( + conn_id: i64, + attribute: i64, + value: i64, +) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "firebird")] + if let Some(Conn::Firebird(connection)) = lock_recover(conns()).get_mut(&conn_id) { + return connection.set_attribute_int(attribute, value) as i64; + } + let _ = (conn_id, attribute, value); + 0 + }) +} + +/// Applies a textual PDO_FIREBIRD date/time formatting attribute. +/// +/// # Safety +/// `value` must point to a NUL-terminated string valid for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_firebird_set_attribute_text( + conn_id: i64, + attribute: i64, + value: *const c_char, +) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "firebird")] + if let (Some(value), Some(Conn::Firebird(connection))) = + (cstr_arg(value), lock_recover(conns()).get_mut(&conn_id)) + { + return connection.set_attribute_text(attribute, value.to_string()) as i64; } - Some(Stmt::Mysql(s)) => { - let conn_id = s.conn_id; - let mut cguard = conns().lock().unwrap(); - match cguard.get_mut(&conn_id) { - Some(Conn::Mysql(c)) => s.step(c), - _ => -1, + let _ = (conn_id, attribute, value); + 0 + }) +} + +/// Reads an integer or boolean PDO_FIREBIRD connection attribute, or `-1` when +/// the attribute/handle is unsupported. +#[no_mangle] +pub extern "C" fn elephc_pdo_firebird_attribute_int(conn_id: i64, attribute: i64) -> i64 { + ffi_guard(-1, || { + #[cfg(feature = "firebird")] + if let Some(Conn::Firebird(connection)) = lock_recover(conns()).get(&conn_id) { + return connection.attribute_int(attribute).unwrap_or(-1); + } + let _ = (conn_id, attribute); + -1 + }) +} + +/// Reads a textual PDO_FIREBIRD date/time formatting attribute. Unsupported +/// attributes and handles return an empty string. +#[no_mangle] +pub extern "C" fn elephc_pdo_firebird_attribute_text( + conn_id: i64, + attribute: i64, +) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let value = { + #[cfg(feature = "firebird")] + if let Some(Conn::Firebird(connection)) = lock_recover(conns()).get(&conn_id) { + connection.attribute_text(attribute).unwrap_or_default().to_string() + } else { + String::new() } + #[cfg(not(feature = "firebird"))] + String::new() + }; + let _ = (conn_id, attribute); + store_cstr(firebird_attribute_cell(), &value) + }) +} + +/// Returns the PDO parameter type reported by PDO_FIREBIRD `getColumnMeta()` for +/// one result column, or `2` (`PDO::PARAM_STR`) for unsupported input. +#[no_mangle] +pub extern "C" fn elephc_pdo_firebird_column_pdo_type(stmt_id: i64, column: i64) -> i64 { + ffi_guard(2, || { + #[cfg(feature = "firebird")] + if let Some(Stmt::Firebird(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_pdo_type(column); } - None => -1, - } + let _ = (stmt_id, column); + 2 + }) } -/// Returns the number of result columns for the statement. +/// Stores PDO_FIREBIRD's statement cursor name after enforcing libfbclient's +/// 31-byte limit. +/// +/// # Safety +/// `name` must point to a NUL-terminated string valid for the duration of the call. #[no_mangle] -pub extern "C" fn elephc_pdo_column_count(stmt_id: i64) -> i64 { - let guard = stmts().lock().unwrap(); - match guard.get(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.column_count(), - Some(Stmt::Postgres(s)) => s.column_count(), - Some(Stmt::Mysql(s)) => s.column_count(), - None => 0, - } +pub unsafe extern "C" fn elephc_pdo_firebird_stmt_set_cursor_name( + stmt_id: i64, + name: *const c_char, +) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "firebird")] + if let (Some(name), Some(Stmt::Firebird(statement))) = + (cstr_arg(name), lock_recover(stmts()).get_mut(&stmt_id)) + { + return statement.set_cursor_name(name.to_string()) as i64; + } + let _ = (stmt_id, name); + 0 + }) } -/// Returns a pointer to the name of result column `i` (0-based). +/// Returns PDO_FIREBIRD's configured statement cursor name, or an empty string +/// when no cursor name is set or the handle belongs to another driver. #[no_mangle] -pub extern "C" fn elephc_pdo_column_name(stmt_id: i64, i: i64) -> *const c_char { - let name = { - let guard = stmts().lock().unwrap(); - match guard.get(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.column_name(i), - Some(Stmt::Postgres(s)) => s.column_name(i), - Some(Stmt::Mysql(s)) => s.column_name(i), - None => String::new(), +pub extern "C" fn elephc_pdo_firebird_stmt_cursor_name(stmt_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let name = { + #[cfg(feature = "firebird")] + if let Some(Stmt::Firebird(statement)) = lock_recover(stmts()).get(&stmt_id) { + statement.cursor_name().unwrap_or_default().to_string() + } else { + String::new() + } + #[cfg(not(feature = "firebird"))] + String::new() + }; + let _ = stmt_id; + store_cstr(firebird_attribute_cell(), &name) + }) +} + +/// Applies PDO_ODBC's writable connection attributes. +#[no_mangle] +pub extern "C" fn elephc_pdo_odbc_set_attribute( + conn_id: i64, + attribute: i64, + value: i64, +) -> i64 { + ffi_guard(0, || { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + if let Some(Conn::Odbc(connection)) = lock_recover(conns()).get_mut(&conn_id) { + return connection.set_attribute(attribute, value) as i64; } - }; - store_cstr(colname_cell(), &name) + let _ = (conn_id, attribute, value); + 0 + }) } -/// Returns the SQLite-compatible type code for the current row's column `i` -/// (0-based): 1=int, 2=float, 3=text, 4=blob/bytea, 5=null. +/// Reads a PDO_ODBC boolean connection attribute, or `-1` when unsupported. #[no_mangle] -pub extern "C" fn elephc_pdo_column_type(stmt_id: i64, i: i64) -> i64 { - let guard = stmts().lock().unwrap(); - match guard.get(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.column_type(i), - Some(Stmt::Postgres(s)) => s.column_type(i), - Some(Stmt::Mysql(s)) => s.column_type(i), - None => 5, - } +pub extern "C" fn elephc_pdo_odbc_attribute(conn_id: i64, attribute: i64) -> i64 { + ffi_guard(-1, || { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + if let Some(Conn::Odbc(connection)) = lock_recover(conns()).get(&conn_id) { + return connection.attribute(attribute).unwrap_or(-1); + } + let _ = (conn_id, attribute); + -1 + }) } -/// Returns the current row's column `i` (0-based) as an integer. +/// Assigns the native cursor name for one PDO_ODBC statement. +/// +/// # Safety +/// `name` must point to a NUL-terminated string valid for the duration of the call. #[no_mangle] -pub extern "C" fn elephc_pdo_column_int(stmt_id: i64, i: i64) -> i64 { - let guard = stmts().lock().unwrap(); - match guard.get(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.column_int(i), - Some(Stmt::Postgres(s)) => s.column_int(i), - Some(Stmt::Mysql(s)) => s.column_int(i), - None => 0, - } +pub unsafe extern "C" fn elephc_pdo_odbc_stmt_set_cursor_name( + stmt_id: i64, + name: *const c_char, +) -> i64 { + ffi_guard(0, || { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + if let (Some(name), Some(Stmt::Odbc(statement))) = + (cstr_arg(name), lock_recover(stmts()).get_mut(&stmt_id)) + { + return statement.set_cursor_name(name) as i64; + } + let _ = (stmt_id, name); + 0 + }) } -/// Returns the current row's column `i` (0-based) as a double. +/// Returns the native cursor name for one PDO_ODBC statement. #[no_mangle] -pub extern "C" fn elephc_pdo_column_double(stmt_id: i64, i: i64) -> f64 { - let guard = stmts().lock().unwrap(); - match guard.get(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.column_double(i), - Some(Stmt::Postgres(s)) => s.column_double(i), - Some(Stmt::Mysql(s)) => s.column_double(i), - None => 0.0, - } +pub extern "C" fn elephc_pdo_odbc_stmt_cursor_name(stmt_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let name = { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get_mut(&stmt_id) { + statement.cursor_name() + } else { + String::new() + } + #[cfg(not(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv")))] + String::new() + }; + let _ = stmt_id; + store_cstr(firebird_attribute_cell(), &name) + }) } -/// Returns a pointer to the current row's column `i` (0-based) text. +/// Mirrors php-src's statement-level ODBC UTF-8 setter return contract. #[no_mangle] -pub extern "C" fn elephc_pdo_column_text(stmt_id: i64, i: i64) -> *const c_char { - let text = { - let guard = stmts().lock().unwrap(); - match guard.get(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.column_text(i), - Some(Stmt::Postgres(s)) => s.column_text(i), - Some(Stmt::Mysql(s)) => s.column_text(i), - None => String::new(), +pub extern "C" fn elephc_pdo_odbc_stmt_set_assume_utf8(stmt_id: i64, enabled: i64) -> i64 { + ffi_guard(0, || { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get_mut(&stmt_id) { + return statement.set_assume_utf8(enabled != 0) as i64; } - }; - store_cstr(coltext_cell(), &text) + let _ = (stmt_id, enabled); + 0 + }) } -/// Returns the byte length of the current row's column `i` rendered as PDO text -/// or BLOB bytes. Unlike `elephc_pdo_column_text`, this path preserves embedded -/// NUL bytes when paired with `elephc_pdo_column_data_ptr`. +/// Mirrors php-src's statement-level ODBC UTF-8 getter return contract. #[no_mangle] -pub extern "C" fn elephc_pdo_column_data_len(stmt_id: i64, i: i64) -> i64 { - let guard = stmts().lock().unwrap(); - match guard.get(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.column_data(i).len() as i64, - Some(Stmt::Postgres(s)) => s.column_data(i).len() as i64, - Some(Stmt::Mysql(s)) => s.column_data(i).len() as i64, - None => 0, - } +pub extern "C" fn elephc_pdo_odbc_stmt_assume_utf8(stmt_id: i64) -> i64 { + ffi_guard(0, || { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.assume_utf8() as i64; + } + let _ = stmt_id; + 0 + }) } -/// Returns a pointer to the current row's column `i` rendered as raw bytes. -/// The pointer remains valid until the next `elephc_pdo_column_data_ptr` call. +/// Applies one PDO_SQLSRV runtime-settable statement attribute. #[no_mangle] -pub extern "C" fn elephc_pdo_column_data_ptr(stmt_id: i64, i: i64) -> *const c_char { - let bytes = { - let guard = stmts().lock().unwrap(); - match guard.get(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.column_data(i), - Some(Stmt::Postgres(s)) => s.column_data(i), - Some(Stmt::Mysql(s)) => s.column_data(i), - None => Vec::new(), +pub extern "C" fn elephc_pdo_sqlsrv_stmt_set_attribute( + stmt_id: i64, + attribute: i64, + value: i64, +) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "sqlsrv")] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get_mut(&stmt_id) { + return statement.set_sqlsrv_attribute(attribute, value) as i64; } - }; - store_bytes(bytes) + let _ = (stmt_id, attribute, value); + 0 + }) } -/// Returns one byte from the current row's column `i` rendered as raw data. -/// Out-of-range handles, columns, and offsets return `0`. +/// Applies one PDO_SQLSRV prepare-only statement option. #[no_mangle] -pub extern "C" fn elephc_pdo_column_data_byte(stmt_id: i64, i: i64, offset: i64) -> i64 { - let Ok(offset) = usize::try_from(offset) else { - return 0; - }; - let bytes = { - let guard = stmts().lock().unwrap(); - match guard.get(&stmt_id) { - Some(Stmt::Sqlite(s)) => s.column_data(i), - Some(Stmt::Postgres(s)) => s.column_data(i), - Some(Stmt::Mysql(s)) => s.column_data(i), - None => Vec::new(), +pub extern "C" fn elephc_pdo_sqlsrv_stmt_configure( + stmt_id: i64, + attribute: i64, + value: i64, +) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "sqlsrv")] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get_mut(&stmt_id) { + return statement.configure_sqlsrv_prepare_option(attribute, value) as i64; } - }; - bytes.get(offset).copied().unwrap_or(0) as i64 + let _ = (stmt_id, attribute, value); + 0 + }) } -/// Finalizes a statement and removes it from the table. Unknown handles return -/// `0`; success returns `1`. +/// Reads one PDO_SQLSRV statement attribute, or `-1` when unsupported. #[no_mangle] -pub extern "C" fn elephc_pdo_finalize(stmt_id: i64) -> i64 { - match stmts().lock().unwrap().remove(&stmt_id) { - Some(Stmt::Sqlite(s)) => { - s.finalize(); - 1 +pub extern "C" fn elephc_pdo_sqlsrv_stmt_attribute(stmt_id: i64, attribute: i64) -> i64 { + ffi_guard(-1, || { + #[cfg(feature = "sqlsrv")] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.sqlsrv_attribute(attribute).unwrap_or(-1); } - Some(Stmt::Postgres(_)) => 1, - Some(Stmt::Mysql(_)) => 1, - None => 0, - } + let _ = (stmt_id, attribute); + -1 + }) } -#[cfg(test)] -mod tests { - use super::*; +/// Reports whether SQLSRV's datetime-fetch mode should create a `DateTime` object. +#[no_mangle] +pub extern "C" fn elephc_pdo_sqlsrv_column_is_datetime(stmt_id: i64, column: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "sqlsrv")] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_is_datetime(column) as i64; + } + let _ = (stmt_id, column); + 0 + }) +} - /// Reads a `*const c_char` bridge string return into an owned `String`. - unsafe fn read(p: *const c_char) -> String { - if p.is_null() { - return String::new(); +/// Returns one PDO_SQLSRV client/server information field as text. +#[no_mangle] +pub extern "C" fn elephc_pdo_sqlsrv_info(conn_id: i64, field: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let value = { + #[cfg(feature = "sqlsrv")] + if let Some(Conn::Odbc(connection)) = lock_recover(conns()).get_mut(&conn_id) { + connection.sqlsrv_info(field) + } else { + String::new() + } + #[cfg(not(feature = "sqlsrv"))] + String::new() + }; + let _ = (conn_id, field); + store_cstr(server_info_cell(), &value) + }) +} + +/// Returns the number of SQLSRV sensitivity pairs for one result column. +#[no_mangle] +pub extern "C" fn elephc_pdo_sqlsrv_classification_pair_count( + stmt_id: i64, + column: i64, +) -> i64 { + ffi_guard(-1, || { + #[cfg(feature = "sqlsrv")] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get_mut(&stmt_id) { + return statement.sqlsrv_classification_pair_count(column); } - CStr::from_ptr(p).to_string_lossy().into_owned() - } + let _ = (stmt_id, column); + -1 + }) +} - /// Builds an owned NUL-terminated C string for the extern-shaped string args. - fn cs(s: &str) -> CString { - CString::new(s).unwrap() - } +/// Returns one SQLSRV sensitivity label or information-type string. +#[no_mangle] +pub extern "C" fn elephc_pdo_sqlsrv_classification_text( + stmt_id: i64, + column: i64, + pair: i64, + field: i64, +) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let value = { + #[cfg(feature = "sqlsrv")] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get_mut(&stmt_id) { + statement.sqlsrv_classification_text(column, pair, field) + } else { + String::new() + } + #[cfg(not(feature = "sqlsrv"))] + String::new() + }; + let _ = (stmt_id, column, pair, field); + store_cstr(sqlsrv_classification_cell(), &value) + }) +} - /// Reads a bridge raw-data pointer and length into owned bytes. - unsafe fn read_bytes(p: *const c_char, len: i64) -> Vec { - if p.is_null() || len <= 0 { - return Vec::new(); +/// Returns one SQLSRV column sensitivity rank, or `-1` when absent. +#[no_mangle] +pub extern "C" fn elephc_pdo_sqlsrv_classification_pair_rank( + stmt_id: i64, + column: i64, + pair: i64, +) -> i64 { + ffi_guard(-1, || { + #[cfg(feature = "sqlsrv")] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get_mut(&stmt_id) { + return statement.sqlsrv_classification_pair_rank(column, pair); } - std::slice::from_raw_parts(p as *const u8, len as usize).to_vec() - } + let _ = (stmt_id, column, pair); + -1 + }) +} - /// The ABI version constant is the v6 (sqlite + pgsql + mysql + raw data + - /// persistent open) surface. - #[test] - fn version_is_v6() { - assert_eq!(elephc_pdo_version(), 6); - } +/// Returns the SQLSRV result-set sensitivity rank, or `-1` when absent. +#[no_mangle] +pub extern "C" fn elephc_pdo_sqlsrv_classification_query_rank(stmt_id: i64) -> i64 { + ffi_guard(-1, || { + #[cfg(feature = "sqlsrv")] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get_mut(&stmt_id) { + return statement.sqlsrv_classification_query_rank(); + } + let _ = stmt_id; + -1 + }) +} - /// A DSN for an unsupported driver is rejected with a driver error. +/// Writes one string-valued PDO_IBM client/trusted-context connection attribute. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_ibm_set_attribute_text( + conn_id: i64, + attribute: i64, + value: *const c_char, +) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "ibm")] + if let (Some(value), Some(Conn::Odbc(connection))) = + (cstr_arg(value), lock_recover(conns()).get_mut(&conn_id)) + { + return connection.set_ibm_attribute_text(attribute, value) as i64; + } + let _ = (conn_id, attribute, value); + 0 + }) +} + +/// Reads one string-valued PDO_IBM client/trusted-context connection attribute. +#[no_mangle] +pub extern "C" fn elephc_pdo_ibm_attribute_text( + conn_id: i64, + attribute: i64, +) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let value = { + #[cfg(feature = "ibm")] + if let Some(Conn::Odbc(connection)) = lock_recover(conns()).get_mut(&conn_id) { + connection.ibm_attribute_text(attribute).unwrap_or_default() + } else { + String::new() + } + #[cfg(not(feature = "ibm"))] + String::new() + }; + let _ = (conn_id, attribute); + store_cstr(ibm_attribute_cell(), &value) + }) +} + +/// Reads PDO_IBM's integer-valued trusted-context enablement attribute. +#[no_mangle] +pub extern "C" fn elephc_pdo_ibm_attribute_int(conn_id: i64, attribute: i64) -> i64 { + ffi_guard(-1, || { + #[cfg(feature = "ibm")] + if let Some(Conn::Odbc(connection)) = lock_recover(conns()).get_mut(&conn_id) { + return connection.ibm_attribute_int(attribute).unwrap_or(-1); + } + let _ = (conn_id, attribute); + -1 + }) +} + +/// Applies an integer-valued PDO_OCI connection attribute. +#[no_mangle] +pub extern "C" fn elephc_pdo_oci_set_attribute_int( + conn_id: i64, + attribute: i64, + value: i64, +) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "oci")] + if let Some(Conn::Oci(connection)) = lock_recover(conns()).get_mut(&conn_id) { + return connection.set_attribute_int(attribute, value) as i64; + } + let _ = (conn_id, attribute, value); + 0 + }) +} + +/// Applies a text-valued PDO_OCI session attribute. +/// +/// # Safety +/// `value` must point to a NUL-terminated string valid for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_oci_set_attribute_text( + conn_id: i64, + attribute: i64, + value: *const c_char, +) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "oci")] + if let (Some(value), Some(Conn::Oci(connection))) = + (cstr_arg(value), lock_recover(conns()).get_mut(&conn_id)) + { + return connection.set_attribute_text(attribute, value) as i64; + } + let _ = (conn_id, attribute, value); + 0 + }) +} + +/// Reads an integer-valued PDO_OCI connection attribute, or `-1` if unsupported. +#[no_mangle] +pub extern "C" fn elephc_pdo_oci_attribute_int(conn_id: i64, attribute: i64) -> i64 { + ffi_guard(-1, || { + #[cfg(feature = "oci")] + if let Some(Conn::Oci(connection)) = lock_recover(conns()).get_mut(&conn_id) { + return connection.attribute_int(attribute).unwrap_or(-1); + } + let _ = (conn_id, attribute); + -1 + }) +} + +/// Returns PDO_OCI's parameter type for one result column. +#[no_mangle] +pub extern "C" fn elephc_pdo_oci_column_pdo_type(stmt_id: i64, column: i64) -> i64 { + ffi_guard(2, || { + #[cfg(feature = "oci")] + if let Some(Stmt::Oci(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_pdo_type(column); + } + let _ = (stmt_id, column); + 2 + }) +} + +/// Returns PDO_OCI's numeric scale for one result column. +#[no_mangle] +pub extern "C" fn elephc_pdo_oci_column_scale(stmt_id: i64, column: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "oci")] + if let Some(Stmt::Oci(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_scale(column); + } + let _ = (stmt_id, column); + 0 + }) +} + +/// Returns PDO_OCI's nullable/not-null/blob metadata flag bits. +#[no_mangle] +pub extern "C" fn elephc_pdo_oci_column_flags(stmt_id: i64, column: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "oci")] + if let Some(Stmt::Oci(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_flags(column); + } + let _ = (stmt_id, column); + 0 + }) +} + +/// Enables or disables SQLite's extended result codes for a `sqlite:` connection +/// (`sqlite3_extended_result_codes`), backing +/// `Pdo\Sqlite::ATTR_EXTENDED_RESULT_CODES` (1002, F-SQLT-02): with it on, +/// `errorInfo()[1]` reports the specific code (e.g. 2067 `SQLITE_CONSTRAINT_UNIQUE`) +/// instead of the primary one (19 `SQLITE_CONSTRAINT`). Returns `1` on success, `0` +/// for a non-SQLite connection, an unknown handle, or a caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_set_extended_result_codes(conn_id: i64, on: i64) -> i64 { + ffi_guard(0, || { + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + Some(Conn::Sqlite(c)) => c.set_extended_result_codes(on), + _ => 0, + } + }) +} + +/// Stores the PHP 8.5 SQLite transaction mode for future `beginTransaction()` calls. +#[no_mangle] +pub extern "C" fn elephc_pdo_set_transaction_mode(conn_id: i64, mode: i64) -> i64 { + ffi_guard(0, || match lock_recover(conns()).get(&conn_id) { + Some(Conn::Sqlite(c)) => c.set_transaction_mode(mode), + _ => 0, + }) +} + +/// Returns the PHP 8.5 SQLite transaction mode, or `-1` for another driver/handle. +#[no_mangle] +pub extern "C" fn elephc_pdo_transaction_mode(conn_id: i64) -> i64 { + ffi_guard(-1, || match lock_recover(conns()).get(&conn_id) { + Some(Conn::Sqlite(c)) => c.transaction_mode(), + _ => -1, + }) +} + +/// Returns a pointer to the connection's server/library version string: SQLite's +/// bundled `sqlite3_libversion()`, or the PostgreSQL/MySQL server's reported +/// version. Empty for an unknown handle — and for a caught panic. Valid until the +/// next `elephc_pdo_server_version`. +#[no_mangle] +pub extern "C" fn elephc_pdo_server_version(conn_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let version = { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.server_version(), + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c.tds_version().to_string(), + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.server_version(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.server_version(), + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.server_version(), + Some(Conn::Sqlite(c)) => c.server_version(), + Some(Conn::Postgres(c)) => c.server_version(), + Some(Conn::Mysql(c)) => c.server_version(), + None => String::new(), + } + }; + store_cstr(server_version_cell(), &version) + }) +} + +/// Returns the connection driver's linked client implementation/version string. +/// SQLite reports the embedded SQLite version exactly like php-src; PostgreSQL and +/// MySQL report their statically linked pure-Rust client crate. Empty for an +/// unknown handle or caught panic. Valid until the next call to this function. +#[no_mangle] +pub extern "C" fn elephc_pdo_client_version(conn_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let version = { + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.client_version(), + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => c.client_version(), + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.client_version(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.client_version(), + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.client_version(), + Some(Conn::Sqlite(c)) => c.client_version(), + Some(Conn::Postgres(c)) => c.client_version(), + Some(Conn::Mysql(c)) => c.client_version(), + None => String::new(), + } + }; + store_cstr(client_version_cell(), &version) + }) +} + +/// Returns the live driver server-information text. SQLite does not implement +/// `PDO::ATTR_SERVER_INFO`, so it returns an empty string for the prelude to route +/// to IM001. Empty also represents an unknown handle, query failure, or panic. +/// Valid until the next call to this function. +#[no_mangle] +pub extern "C" fn elephc_pdo_server_info(conn_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let info = { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => c.server_version(), + #[cfg(feature = "dblib")] + Some(Conn::Dblib(_)) => String::new(), + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.server_version(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(c)) => c.server_info(), + #[cfg(feature = "oci")] + Some(Conn::Oci(c)) => c.server_info(), + Some(Conn::Postgres(c)) => c.server_info(), + Some(Conn::Mysql(c)) => c.server_info(), + Some(Conn::Sqlite(_)) | None => String::new(), + } + }; + store_cstr(server_info_cell(), &info) + }) +} + +/// Returns the driver's connection-status text. PostgreSQL maps its live closed +/// state to libpq's status strings; MySQL returns its resolved transport description. +/// SQLite does not implement the attribute and returns empty. Valid until the next +/// call to this function; empty also covers an unknown handle or caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_connection_status(conn_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let status = { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(c)) => { + if c.is_alive() { "Connection OK".to_string() } else { "Connection failed".to_string() } + } + #[cfg(feature = "dblib")] + Some(Conn::Dblib(c)) => { + if c.is_alive() { "Connection OK".to_string() } else { "Connection failed".to_string() } + } + #[cfg(feature = "firebird")] + Some(Conn::Firebird(c)) => c.connection_status(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(_)) => String::new(), + #[cfg(feature = "oci")] + Some(Conn::Oci(_)) => String::new(), + Some(Conn::Postgres(c)) => c.connection_status(), + Some(Conn::Mysql(c)) => c.connection_status(), + Some(Conn::Sqlite(_)) | None => String::new(), + } + }; + store_cstr(connection_status_cell(), &status) + }) +} + +/// Returns the PostgreSQL backend process id for a `pgsql:` connection (backs +/// `Pdo\Pgsql::getPid()`); 0 for a SQLite/MySQL connection, an unknown handle, or a +/// caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_backend_pid(conn_id: i64) -> i64 { + ffi_guard(0, || { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(_)) => 0, + #[cfg(feature = "dblib")] + Some(Conn::Dblib(_)) => 0, + #[cfg(feature = "firebird")] + Some(Conn::Firebird(_)) => 0, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(_)) => 0, + #[cfg(feature = "oci")] + Some(Conn::Oci(_)) => 0, + Some(Conn::Postgres(c)) => c.backend_pid(), + Some(Conn::Sqlite(_)) => 0, + Some(Conn::Mysql(_)) => 0, + None => 0, + } + }) +} + +/// Returns the number of warnings from the last statement on a `mysql:` connection +/// (backs `Pdo\Mysql::getWarningCount()`); 0 for a SQLite/PostgreSQL connection, an +/// unknown handle, or a caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_warning_count(conn_id: i64) -> i64 { + ffi_guard(0, || { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(_)) => 0, + #[cfg(feature = "dblib")] + Some(Conn::Dblib(_)) => 0, + #[cfg(feature = "firebird")] + Some(Conn::Firebird(_)) => 0, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(_)) => 0, + #[cfg(feature = "oci")] + Some(Conn::Oci(_)) => 0, + Some(Conn::Mysql(c)) => c.warning_count(), + Some(Conn::Sqlite(_)) => 0, + Some(Conn::Postgres(_)) => 0, + None => 0, + } + }) +} + +/// Returns `1` when a `mysql:` connection's session has `NO_BACKSLASH_ESCAPES` +/// active in its `sql_mode` (backslash is then a literal character in a string +/// literal, so `PDO::quote()`'s usual backslash-escaping is unsafe there and +/// must fall back to `''`-doubling only — P1-f); `0` for a SQLite/PostgreSQL +/// connection, an unknown handle, or a caught panic (`0` = "escape as usual", the +/// conservative answer, since the connection's real `sql_mode` could not be read). +#[no_mangle] +pub extern "C" fn elephc_pdo_no_backslash_escapes(conn_id: i64) -> i64 { + ffi_guard(0, || { + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + Some(Conn::Mysql(c)) => c.no_backslash_escape() as i64, + _ => 0, + } + }) +} + +/// Creates a large object and returns its OID as text for a `pgsql:` connection +/// (`Pdo\Pgsql::lobCreate()`); empty string for a non-PostgreSQL connection, an +/// unknown handle, an error, or a caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_lob_create(conn_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let text = { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + Some(Conn::Postgres(c)) => c.lob_create(), + _ => String::new(), + } + }; + store_cstr(pg_text_result_cell(), &text) + }) +} + +/// Deletes a large object by OID for a `pgsql:` connection (`Pdo\Pgsql::lobUnlink()`); +/// returns 1 on success, 0 for a non-PostgreSQL connection, unknown handle, error, or +/// a caught panic. +/// +/// # Safety +/// `oid` must point to a NUL-terminated string valid for the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_lob_unlink(conn_id: i64, oid: *const c_char) -> i64 { + ffi_guard(0, || { + let Some(oid) = cstr_arg(oid) else { + return 0; + }; + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + Some(Conn::Postgres(c)) => c.lob_unlink(oid), + _ => 0, + } + }) +} + +/// Runs a prelude-built `COPY … FROM STDIN` for a `pgsql:` connection, streaming +/// `data` into it (`Pdo\Pgsql::copyFromArray()` / `copyFromFile()`); returns the row +/// count copied, or -1 for a non-PostgreSQL connection, unknown handle, error, or a +/// caught panic. +/// +/// # Safety +/// `copy_sql` and `data` must point to NUL-terminated strings valid for the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_copy_in( + conn_id: i64, + copy_sql: *const c_char, + data: *const c_char, +) -> i64 { + ffi_guard(-1, || { + let (Some(sql), Some(data)) = (cstr_arg(copy_sql), cstr_arg(data)) else { + return -1; + }; + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + Some(Conn::Postgres(c)) => c.copy_in(sql, data.as_bytes()), + _ => -1, + } + }) +} + +/// Runs a prelude-built `COPY … TO STDOUT` for a `pgsql:` connection and returns the +/// raw text output (`Pdo\Pgsql::copyToArray()` / `copyToFile()`); empty string for a +/// non-PostgreSQL connection, unknown handle, or error. P2-i: a caller cannot tell +/// "really empty" apart from "error" by this return value alone — `copy_out` always +/// resets `elephc_pdo_errcode()` to `0` on success (even an empty one) and sets it +/// non-zero on error, so the prelude checks that accessor right after this call to +/// make the distinction, satisfying `copyToArray()`'s `array|false` contract. A +/// caught panic degrades to that same empty string. +/// +/// # Safety +/// `copy_sql` must point to a NUL-terminated string valid for the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_copy_out( + conn_id: i64, + copy_sql: *const c_char, +) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let Some(sql) = cstr_arg(copy_sql) else { + return store_cstr(pg_text_result_cell(), ""); + }; + let text = { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + Some(Conn::Postgres(c)) => c.copy_out(sql), + _ => String::new(), + } + }; + store_cstr(pg_text_result_cell(), &text) + }) +} + +/// Polls a `pgsql:` connection for a pending LISTEN/NOTIFY notification +/// (`Pdo\Pgsql::getNotify()`), returning it as `channel\tpid\tpayload`, or an empty +/// string if none arrives within `timeout_ms` (or for a non-PostgreSQL connection / +/// unknown handle / a caught panic). +#[no_mangle] +pub extern "C" fn elephc_pdo_get_notify(conn_id: i64, timeout_ms: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let text = { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + Some(Conn::Postgres(c)) => c.get_notify(timeout_ms), + _ => String::new(), + } + }; + store_cstr(pg_text_result_cell(), &text) + }) +} + +/// Drains one buffered server NOTICE message from a `pgsql:` connection +/// (`Pdo\Pgsql::setNoticeCallback()`), returning its text, or an empty string when +/// none is pending (or for a non-PostgreSQL connection / unknown handle). The prelude +/// calls this in a loop after each `exec()`/`query()` and dispatches each message to +/// the registered PHP callback. The returned pointer is valid until the next +/// PostgreSQL text-returning bridge call on this thread. A caught panic reports the +/// same empty string as "nothing pending", which simply ends the prelude's drain loop. +#[no_mangle] +pub extern "C" fn elephc_pdo_get_notice(conn_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let text = { + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + Some(Conn::Postgres(c)) => c.drain_notice(), + _ => String::new(), + } + }; + store_cstr(pg_text_result_cell(), &text) + }) +} + +/// Reads a SQLite BLOB cell whole into the shared blob buffer (`Pdo\Sqlite::openBlob()`), +/// returning its length in bytes, or -1 for a non-SQLite connection, an unknown handle, +/// a read error (missing row/column), or a caught panic. The bytes are then copied out +/// in one shot with `elephc_pdo_blob_data_ptr` (or, on the fallback path, drained with +/// `elephc_pdo_blob_byte`); both preserve embedded NUL bytes. +/// +/// # Safety +/// `table`, `column`, and `dbname` must point to NUL-terminated strings valid for the +/// call (`dbname` may be null, treated as `"main"`). +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_blob_read( + conn_id: i64, + table: *const c_char, + column: *const c_char, + rowid: i64, + dbname: *const c_char, +) -> i64 { + ffi_guard(-1, || { + let (Some(table), Some(column)) = (cstr_arg(table), cstr_arg(column)) else { + return -1; + }; + let dbname = cstr_arg(dbname).unwrap_or("main"); + let result = { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + Some(Conn::Sqlite(c)) => c.blob_read(dbname, table, column, rowid), + _ => return -1, + } + }; + match result { + Ok(bytes) => { + let len = bytes.len() as i64; + *lock_recover(blob_cell()) = bytes; + len + } + Err(_) => -1, + } + }) +} + +/// Returns a SQLite BLOB cell's fixed byte size without transferring its data, +/// or `-1` for invalid input, an unknown/non-SQLite handle, or a SQLite failure. +/// +/// # Safety +/// `table`, `column`, and `dbname` must point to NUL-terminated strings valid for +/// the call (`dbname` may be null, treated as `"main"`). +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_blob_size( + conn_id: i64, + table: *const c_char, + column: *const c_char, + rowid: i64, + dbname: *const c_char, +) -> i64 { + ffi_guard(-1, || { + let (Some(table), Some(column)) = (cstr_arg(table), cstr_arg(column)) else { + return -1; + }; + let dbname = cstr_arg(dbname).unwrap_or("main"); + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + Some(Conn::Sqlite(connection)) => connection + .blob_size(dbname, table, column, rowid) + .unwrap_or(-1), + _ => -1, + } + }) +} + +/// Reads one bounded SQLite BLOB slice into the shared binary buffer and returns +/// its length, or `-1` for invalid input, a bad handle, or a SQLite failure. +/// +/// # Safety +/// `table`, `column`, and `dbname` must point to NUL-terminated strings valid for +/// the call (`dbname` may be null, treated as `"main"`). +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_blob_read_at( + conn_id: i64, + table: *const c_char, + column: *const c_char, + rowid: i64, + dbname: *const c_char, + offset: i64, + length: i64, +) -> i64 { + ffi_guard(-1, || { + let (Some(table), Some(column)) = (cstr_arg(table), cstr_arg(column)) else { + return -1; + }; + let dbname = cstr_arg(dbname).unwrap_or("main"); + let result = { + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + Some(Conn::Sqlite(connection)) => { + connection.blob_read_at(dbname, table, column, rowid, offset, length) + } + _ => return -1, + } + }; + match result { + Ok(bytes) => { + let length = bytes.len() as i64; + *lock_recover(blob_cell()) = bytes; + length + } + Err(_) => -1, + } + }) +} + +/// Writes one bounded slice of an existing fixed-size SQLite BLOB and returns the +/// bytes written, or `-1` when the range would extend it or another error occurs. +/// +/// # Safety +/// String identifiers must be valid NUL-terminated strings for the call, and +/// `data` must expose at least `len` readable bytes (it may be null when `len` is 0). +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_blob_write_at( + conn_id: i64, + table: *const c_char, + column: *const c_char, + rowid: i64, + dbname: *const c_char, + offset: i64, + data: *const c_char, + len: i64, +) -> i64 { + ffi_guard(-1, || { + if len < 0 { + return -1; + } + let (Some(table), Some(column)) = (cstr_arg(table), cstr_arg(column)) else { + return -1; + }; + let dbname = cstr_arg(dbname).unwrap_or("main"); + let bytes = bytes_arg(data, len); + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + Some(Conn::Sqlite(connection)) => connection + .blob_write_at(dbname, table, column, rowid, offset, &bytes) + .unwrap_or(-1), + _ => -1, + } + }) +} + +/// Reads a PostgreSQL large object whole into the shared blob buffer for pre-v45 +/// ABI callers, returning its length in bytes, or -1 for a +/// non-PostgreSQL connection, an unknown handle, a non-numeric OID, a server error +/// (no such object), or a caught panic. The bytes are then copied out with +/// `elephc_pdo_blob_data_ptr` (or drained with `elephc_pdo_blob_byte`). +/// +/// # Safety +/// `oid` must point to a NUL-terminated string valid for the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_lob_get(conn_id: i64, oid: *const c_char) -> i64 { + ffi_guard(-1, || { + let Some(oid) = cstr_arg(oid) else { + return -1; + }; + let result = { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + Some(Conn::Postgres(c)) => c.lob_get(oid), + _ => return -1, + } + }; + match result { + Some(bytes) => { + let len = bytes.len() as i64; + *lock_recover(blob_cell()) = bytes; + len + } + None => -1, + } + }) +} + +/// Returns a PostgreSQL large object's current size without transferring its data. +/// +/// # Safety +/// `oid` must point to a NUL-terminated string valid for the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_lob_size(conn_id: i64, oid: *const c_char) -> i64 { + ffi_guard(-1, || { + let Some(oid) = cstr_arg(oid) else { + return -1; + }; + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + Some(Conn::Postgres(connection)) => connection.lob_size(oid).unwrap_or(-1), + _ => -1, + } + }) +} + +/// Reads one PostgreSQL large-object slice into the shared binary buffer and +/// returns its length, or `-1` on invalid input/server failure. +/// +/// # Safety +/// `oid` must point to a NUL-terminated string valid for the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_lob_read_at( + conn_id: i64, + oid: *const c_char, + offset: i64, + length: i64, +) -> i64 { + ffi_guard(-1, || { + let Some(oid) = cstr_arg(oid) else { + return -1; + }; + let result = { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + Some(Conn::Postgres(connection)) => connection.lob_read_at(oid, offset, length), + _ => return -1, + } + }; + match result { + Some(bytes) => { + let length = bytes.len() as i64; + *lock_recover(blob_cell()) = bytes; + length + } + None => -1, + } + }) +} + +/// Writes one PostgreSQL large-object slice at an explicit byte offset. +/// +/// # Safety +/// `oid` must be a valid NUL-terminated string and `data` must expose at least +/// `len` readable bytes (it may be null when `len` is zero). +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_lob_write_at( + conn_id: i64, + oid: *const c_char, + offset: i64, + data: *const c_char, + len: i64, +) -> i64 { + ffi_guard(-1, || { + let Some(oid) = cstr_arg(oid) else { + return -1; + }; + let bytes = bytes_arg(data, len); + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + Some(Conn::Postgres(connection)) => connection.lob_write_at(oid, offset, &bytes), + _ => -1, + } + }) +} + +/// Writes the complete fixed-size snapshot of a SQLite BLOB back through +/// `sqlite3_blob_write`, returning 1 on success and 0 for a bad handle, invalid +/// identifiers, a size change, a SQLite error, or a caught panic. +/// +/// # Safety +/// The string arguments must be valid NUL-terminated strings for the call, and +/// `data` must expose at least `len` readable bytes (it may be null when `len` is 0). +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_blob_write( + conn_id: i64, + table: *const c_char, + column: *const c_char, + rowid: i64, + dbname: *const c_char, + data: *const c_char, + len: i64, +) -> i64 { + ffi_guard(0, || { + let (Some(table), Some(column)) = (cstr_arg(table), cstr_arg(column)) else { + return 0; + }; + let dbname = cstr_arg(dbname).unwrap_or("main"); + let bytes = bytes_arg(data, len); + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + Some(Conn::Sqlite(c)) => c + .blob_write(dbname, table, column, rowid, &bytes) + .is_ok() as i64, + _ => 0, + } + }) +} + +/// Writes a complete PostgreSQL large-object snapshot at offset zero with +/// `lo_put`, returning 1 on success and 0 for an invalid OID/handle, server error, +/// or caught panic. Embedded NUL bytes are preserved by the explicit byte length. +/// +/// # Safety +/// `oid` must be a valid NUL-terminated string for the call, and `data` must expose +/// at least `len` readable bytes (it may be null when `len` is 0). +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_lob_put( + conn_id: i64, + oid: *const c_char, + data: *const c_char, + len: i64, +) -> i64 { + ffi_guard(0, || { + let Some(oid) = cstr_arg(oid) else { + return 0; + }; + let bytes = bytes_arg(data, len); + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + Some(Conn::Postgres(c)) => c.lob_put(oid, &bytes), + _ => 0, + } + }) +} + +/// Returns a pointer to the first byte of the shared blob buffer filled by the most +/// recent `elephc_pdo_blob_read` / `elephc_pdo_lob_get`, or a NULL pointer when that +/// buffer is empty (which is also the caught-panic sentinel). Mirrors the +/// `elephc_pdo_column_data_ptr` contract: the pointer is valid until the next call +/// that rewrites the cell, and the prelude copies the whole run of +/// `elephc_pdo_blob_read`-reported bytes out immediately through `ptr_read_string`. +/// It exists so a BLOB is bulk-copied in one call instead of one PHP-level bridge +/// call per byte through `elephc_pdo_blob_byte` (kept as the fallback/compat path). +#[no_mangle] +pub extern "C" fn elephc_pdo_blob_data_ptr() -> *const c_char { + ffi_guard(std::ptr::null(), || { + let guard = lock_recover(blob_cell()); + if guard.is_empty() { + std::ptr::null() + } else { + guard.as_ptr() as *const c_char + } + }) +} + +/// Returns the byte at `offset` in the shared blob buffer populated by the most recent +/// `elephc_pdo_blob_read` / `elephc_pdo_lob_get`, or 0 when out of range (or on a caught +/// panic). This is the fallback/compat drain path — one bridge call per byte — kept +/// alongside the bulk `elephc_pdo_blob_data_ptr`; like `elephc_pdo_column_data_byte`, +/// it preserves embedded NUL bytes on the round-trip into a PHP string. +#[no_mangle] +pub extern "C" fn elephc_pdo_blob_byte(offset: i64) -> i64 { + ffi_guard(0, || { + if offset < 0 { + return 0; + } + let guard = lock_recover(blob_cell()); + guard.get(offset as usize).map(|&b| b as i64).unwrap_or(0) + }) +} + +/// Prepares a statement (`PDO::prepare` / `PDO::query`) and returns an `i64` +/// statement handle, or `-1` on a compile error — the same sentinel a caught panic +/// degrades to. +/// +/// # Safety +/// `sql` must point to a NUL-terminated string valid for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_prepare( + conn_id: i64, + sql: *const c_char, + emulated: i64, +) -> i64 { + ffi_guard(-1, || { + let sqlite_db = match lock_recover(conns()).get(&conn_id) { + Some(Conn::Sqlite(connection)) => Some(connection.db), + _ => None, + }; + let prepared: Result = if let Some(db) = sqlite_db { + sqlite::SqliteConn::prepare_on(db, sql).map(Stmt::Sqlite) + } else { + let mut guard = lock_recover(conns()); + match guard.get_mut(&conn_id) { + #[cfg(feature = "cubrid")] + Some(Conn::Cubrid(connection)) => match cstr_arg(sql) { + Some(sql) => cubrid::CubridStmt::new(connection, conn_id, sql) + .map(Stmt::Cubrid) + .map_err(|_| ()), + None => Err(()), + }, + #[cfg(feature = "dblib")] + Some(Conn::Dblib(connection)) => match cstr_arg(sql) { + Some(sql) => match dblib::DblibStmt::new(conn_id, sql) { + Ok(statement) => Ok(Stmt::Dblib(statement)), + Err(message) => { + connection.set_error("HY093", 0, message); + Err(()) + } + }, + None => Err(()), + }, + #[cfg(feature = "firebird")] + Some(Conn::Firebird(connection)) => match cstr_arg(sql) { + Some(sql) => match firebird::FirebirdStmt::new(conn_id, sql) { + Ok(statement) => Ok(Stmt::Firebird(statement)), + Err(message) => { + connection.set_error("HY093", message); + Err(()) + } + }, + None => Err(()), + }, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Conn::Odbc(connection)) => match cstr_arg(sql) { + Some(sql) => odbc::OdbcStmt::new(connection, conn_id, sql, emulated) + .map(Stmt::Odbc) + .map_err(|_| ()), + None => Err(()), + }, + #[cfg(feature = "oci")] + Some(Conn::Oci(connection)) => match cstr_arg(sql) { + Some(sql) => oci::OciStmt::new(connection, conn_id, sql) + .map(Stmt::Oci) + .map_err(|_| ()), + None => Err(()), + }, + Some(Conn::Postgres(c)) => match cstr_arg(sql) { + Some(s) => match c.prepare(s, emulated != 0) { + Ok(mut st) => { + st.conn_id = conn_id; + Ok(Stmt::Postgres(st)) + } + Err(_) => Err(()), + }, + None => Err(()), + }, + Some(Conn::Mysql(c)) => match cstr_arg(sql) { + Some(s) => match c.prepare(s, emulated != 0) { + Ok(mut st) => { + st.conn_id = conn_id; + Ok(Stmt::Mysql(st)) + } + Err(_) => Err(()), + }, + None => Err(()), + }, + Some(Conn::Sqlite(_)) | None => Err(()), + } + }; + match prepared { + Ok(stmt) => { + let id = next_id(); + lock_recover(stmts()).insert(id, stmt); + id + } + Err(()) => -1, + } + }) +} + +/// Resolves a named placeholder to its 1-based bind index, or `0` when unknown (also +/// the caught-panic sentinel). +/// +/// # Safety +/// `name` must point to a NUL-terminated string valid for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_bind_parameter_index(stmt_id: i64, name: *const c_char) -> i64 { + ffi_guard(0, || { + let guard = lock_recover(stmts()); + let Some(name) = cstr_arg(name) else { + return 0; + }; + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.parameter_index(name), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.parameter_index(name), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.parameter_index(name), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.parameter_index(name), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.parameter_index(name), + Some(Stmt::Sqlite(s)) => s.bind_parameter_index(name), + Some(Stmt::Postgres(s)) => s.bind_parameter_index(name), + Some(Stmt::Mysql(s)) => s.bind_parameter_index(name), + None => 0, + } + }) +} + +/// Binds an integer to the 1-based placeholder `idx`. Returns `1`/`0`; a caught panic +/// reports the `0` failure sentinel. +#[no_mangle] +pub extern "C" fn elephc_pdo_bind_int(stmt_id: i64, idx: i64, val: i64) -> i64 { + ffi_guard(0, || { + let mut guard = lock_recover(stmts()); + match guard.get_mut(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.bind_int(idx, val) as i64, + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.bind_int(idx, val) as i64, + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.bind_int(idx, val) as i64, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.bind_int(idx, val) as i64, + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.bind_int(idx, val) as i64, + Some(Stmt::Sqlite(s)) => s.bind_int(idx, val), + Some(Stmt::Postgres(s)) => s.bind(idx, pg::Bind::Int(val)), + Some(Stmt::Mysql(s)) => s.bind(idx, my::Bind::Int(val)), + None => 0, + } + }) +} + +/// Binds a double to the 1-based placeholder `idx`. Returns `1`/`0`; a caught panic +/// reports the `0` failure sentinel. +#[no_mangle] +pub extern "C" fn elephc_pdo_bind_double(stmt_id: i64, idx: i64, val: f64) -> i64 { + ffi_guard(0, || { + let mut guard = lock_recover(stmts()); + match guard.get_mut(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.bind_double(idx, val) as i64, + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.bind_double(idx, val) as i64, + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.bind_double(idx, val) as i64, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.bind_double(idx, val) as i64, + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.bind_double(idx, val) as i64, + Some(Stmt::Sqlite(s)) => s.bind_double(idx, val), + Some(Stmt::Postgres(s)) => s.bind(idx, pg::Bind::Float(val)), + Some(Stmt::Mysql(s)) => s.bind(idx, my::Bind::Float(val)), + None => 0, + } + }) +} + +/// Binds a text value to the 1-based placeholder `idx`, using the +/// caller-supplied `len` (the value's true byte length) rather than a +/// NUL-terminated-string decode, so a value with an embedded NUL byte binds in +/// full instead of silently truncating at the first NUL (v20/P0-A). A null +/// pointer binds SQL NULL. Returns `1`/`0`; a caught panic reports the `0` failure +/// sentinel. +/// +/// # Safety +/// `val`, when non-null, must point to at least `len` readable bytes valid for +/// the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_bind_text( + stmt_id: i64, + idx: i64, + val: *const c_char, + len: i64, +) -> i64 { + ffi_guard(0, || { + let mut guard = lock_recover(stmts()); + match guard.get_mut(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => { + if val.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_text(idx, bytes_arg(val, len)) as i64 + } + } + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => { + if val.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_text(idx, bytes_arg(val, len), false) as i64 + } + } + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => { + if val.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_text(idx, bytes_arg(val, len)) as i64 + } + } + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => { + if val.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_text(idx, bytes_arg(val, len)) as i64 + } + } + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => { + if val.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_text(idx, bytes_arg(val, len)) as i64 + } + } + Some(Stmt::Sqlite(s)) => s.bind_text(idx, val, len), + Some(Stmt::Postgres(s)) => { + let bind = if val.is_null() { + pg::Bind::Null + } else { + pg::Bind::Text(String::from_utf8_lossy(&bytes_arg(val, len)).into_owned()) + }; + s.bind(idx, bind) + } + Some(Stmt::Mysql(s)) => { + let bind = if val.is_null() { + my::Bind::Null + } else { + my::Bind::Text(String::from_utf8_lossy(&bytes_arg(val, len)).into_owned()) + }; + s.bind(idx, bind) + } + None => 0, + } + }) +} + +/// Binds a string with MySQL's national-character marker. SQLite and PostgreSQL +/// treat it as ordinary text; MySQL's emulated-prepare renderer emits `N'…'`, +/// while native prepares send the same byte payload as an ordinary string. +/// +/// # Safety +/// `val`, when non-null, must point to at least `len` readable bytes valid for +/// the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_bind_text_national( + stmt_id: i64, + idx: i64, + val: *const c_char, + len: i64, +) -> i64 { + ffi_guard(0, || { + let mut guard = lock_recover(stmts()); + match guard.get_mut(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => { + if val.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_text(idx, bytes_arg(val, len)) as i64 + } + } + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => { + if val.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_text(idx, bytes_arg(val, len), true) as i64 + } + } + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => { + if val.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_text(idx, bytes_arg(val, len)) as i64 + } + } + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => { + if val.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_text(idx, bytes_arg(val, len)) as i64 + } + } + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => { + if val.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_text(idx, bytes_arg(val, len)) as i64 + } + } + Some(Stmt::Sqlite(s)) => s.bind_text(idx, val, len), + Some(Stmt::Postgres(s)) => { + let bind = if val.is_null() { + pg::Bind::Null + } else { + pg::Bind::Text(String::from_utf8_lossy(&bytes_arg(val, len)).into_owned()) + }; + s.bind(idx, bind) + } + Some(Stmt::Mysql(s)) => { + let bind = if val.is_null() { + my::Bind::Null + } else { + my::Bind::NationalText( + String::from_utf8_lossy(&bytes_arg(val, len)).into_owned(), + ) + }; + s.bind(idx, bind) + } + None => 0, + } + }) +} + +/// Binds SQL NULL to the 1-based placeholder `idx`. Returns `1`/`0`; a caught panic +/// reports the `0` failure sentinel. +#[no_mangle] +pub extern "C" fn elephc_pdo_bind_null(stmt_id: i64, idx: i64) -> i64 { + ffi_guard(0, || { + let mut guard = lock_recover(stmts()); + match guard.get_mut(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.bind_null(idx) as i64, + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.bind_null(idx) as i64, + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.bind_null(idx) as i64, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.bind_null(idx) as i64, + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.bind_null(idx) as i64, + Some(Stmt::Sqlite(s)) => s.bind_null(idx), + Some(Stmt::Postgres(s)) => s.bind(idx, pg::Bind::Null), + Some(Stmt::Mysql(s)) => s.bind(idx, my::Bind::Null), + None => 0, + } + }) +} + +/// Binds a boolean to the 1-based placeholder `idx`: SQLite and MySQL bind it as +/// an integer `0`/`1`; PostgreSQL binds a real boolean value through the text +/// `'t'`/`'f'` parameter format PostgreSQL accepts for `bool` columns (and +/// coerces from for untyped/text columns, matching PDO/PHP's text-parameter +/// convention). Returns `1`/`0`; a caught panic reports the `0` failure sentinel. +#[no_mangle] +pub extern "C" fn elephc_pdo_bind_bool(stmt_id: i64, idx: i64, val: i64) -> i64 { + ffi_guard(0, || { + let truthy = (val != 0) as i64; + let mut guard = lock_recover(stmts()); + match guard.get_mut(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.bind_int(idx, truthy) as i64, + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.bind_int(idx, truthy) as i64, + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.bind_int(idx, truthy) as i64, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.bind_int(idx, truthy) as i64, + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.bind_int(idx, truthy) as i64, + Some(Stmt::Sqlite(s)) => s.bind_int(idx, truthy), + Some(Stmt::Postgres(s)) => { + let text = if truthy != 0 { "t" } else { "f" }; + s.bind(idx, pg::Bind::Text(text.to_string())) + } + Some(Stmt::Mysql(s)) => s.bind(idx, my::Bind::Int(truthy)), + None => 0, + } + }) +} + +/// Binds raw bytes (embedded NUL preserved) to the 1-based placeholder `idx`: +/// SQLite copies them via `SQLITE_TRANSIENT` (`sqlite3_bind_blob`); PostgreSQL and +/// MySQL bind them through each driver's raw-bytes value path (bypassing the +/// text re-encoding the other bind functions use), so arbitrary binary content +/// round-trips unchanged. Returns `1`/`0`; a caught panic reports the `0` failure +/// sentinel. +/// +/// # Safety +/// `ptr`, when non-null, must point to at least `len` readable bytes valid for +/// the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_bind_blob( + stmt_id: i64, + idx: i64, + ptr: *const c_char, + len: i64, +) -> i64 { + ffi_guard(0, || { + let mut guard = lock_recover(stmts()); + match guard.get_mut(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => { + if ptr.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_blob(idx, bytes_arg(ptr, len)) as i64 + } + } + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => { + if ptr.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_blob(idx, bytes_arg(ptr, len)) as i64 + } + } + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => { + if ptr.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_blob(idx, bytes_arg(ptr, len)) as i64 + } + } + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => { + if ptr.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_blob(idx, bytes_arg(ptr, len)) as i64 + } + } + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => { + if ptr.is_null() { + s.bind_null(idx) as i64 + } else { + s.bind_blob(idx, bytes_arg(ptr, len)) as i64 + } + } + Some(Stmt::Sqlite(s)) => s.bind_blob(idx, ptr, len), + Some(Stmt::Postgres(s)) => { + let bind = if ptr.is_null() { + pg::Bind::Null + } else { + pg::Bind::Bytes(bytes_arg(ptr, len)) + }; + s.bind(idx, bind) + } + Some(Stmt::Mysql(s)) => { + let bind = if ptr.is_null() { + my::Bind::Null + } else { + my::Bind::Bytes(bytes_arg(ptr, len)) + }; + s.bind(idx, bind) + } + None => 0, + } + }) +} + +/// Marks a one-based native bind as input/output and records its output buffer size. +/// Drivers without output-parameter support accept the common ABI call as a no-op. +#[no_mangle] +pub extern "C" fn elephc_pdo_bind_output( + stmt_id: i64, + idx: i64, + pdo_type: i64, + max_length: i64, +) -> i64 { + ffi_guard(0, || { + let _ = (idx, pdo_type, max_length); + let mut guard = lock_recover(stmts()); + match guard.get_mut(&stmt_id) { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(statement)) => { + statement.bind_output(idx, pdo_type, max_length) + } + #[cfg(feature = "oci")] + Some(Stmt::Oci(statement)) => { + statement.bind_output(idx, pdo_type, max_length) as i64 + } + Some(_) => 1, + None => 0, + } + }) +} + +/// Copies one completed native output bind into the shared binary buffer. +/// Returns its byte length, `-2` for SQL NULL, or `-3` when the slot is not an +/// native OCI/CLI output bind. +#[no_mangle] +pub extern "C" fn elephc_pdo_output_data(stmt_id: i64, idx: i64) -> i64 { + ffi_guard(-3, || { + let _ = (stmt_id, idx); + let value: Option>> = { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(statement)) => { + statement.output_value(idx).map(|value| value.data.clone()) + } + #[cfg(feature = "oci")] + Some(Stmt::Oci(statement)) => { + statement.output_value(idx).map(|value| value.data.clone()) + } + _ => None, + } + }; + let Some(value) = value else { + return -3; + }; + let Some(data) = value else { + return -2; + }; + let length = data.len() as i64; + *lock_recover(blob_cell()) = data; + length + }) +} + +/// Reports whether one completed native output bind is a LOB locator. +#[no_mangle] +pub extern "C" fn elephc_pdo_output_is_lob(stmt_id: i64, idx: i64) -> i64 { + ffi_guard(0, || { + let _ = idx; + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(statement)) => statement + .output_value(idx) + .map_or(0, |value| value.lob as i64), + #[cfg(feature = "oci")] + Some(Stmt::Oci(statement)) => statement + .output_value(idx) + .map_or(0, |value| value.lob as i64), + _ => 0, + } + }) +} + +/// Reports whether one completed CLI output bind used PDO_IBM's native integer path. +#[no_mangle] +pub extern "C" fn elephc_pdo_output_is_numeric(stmt_id: i64, idx: i64) -> i64 { + ffi_guard(0, || { + let _ = idx; + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(statement)) => statement + .output_value(idx) + .map_or(0, |value| value.numeric as i64), + _ => 0, + } + }) +} + +/// Resets a statement, keeping its parameter bindings. Returns `1`/`0`; a caught +/// panic reports the `0` failure sentinel. +#[no_mangle] +pub extern "C" fn elephc_pdo_reset(stmt_id: i64) -> i64 { + ffi_guard(0, || { + let mut guard = lock_recover(stmts()); + match guard.get_mut(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => { + s.reset(); + 1 + } + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => { + s.reset(); + 1 + } + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => { + s.reset(); + 1 + } + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => { + s.reset(); + 1 + } + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => { + s.reset(); + 1 + } + Some(Stmt::Sqlite(s)) => s.reset(), + Some(Stmt::Postgres(s)) => { + let mut connections = lock_recover(conns()); + match connections.get_mut(&s.conn_id) { + Some(Conn::Postgres(connection)) => s.reset(connection), + _ => 0, + } + } + Some(Stmt::Mysql(s)) => { + if let Some(Conn::Mysql(connection)) = lock_recover(conns()).get_mut(&s.conn_id) { + return s.reset(connection); + } + 0 + } + None => 0, + } + }) +} + +/// Clears all parameter bindings on a statement. Returns `1`/`0`; a caught panic +/// reports the `0` failure sentinel. +#[no_mangle] +pub extern "C" fn elephc_pdo_clear_bindings(stmt_id: i64) -> i64 { + ffi_guard(0, || { + let mut guard = lock_recover(stmts()); + match guard.get_mut(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => { + s.clear_bindings(); + 1 + } + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => { + s.clear_bindings(); + 1 + } + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => { + s.clear_bindings(); + 1 + } + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => { + s.clear_bindings(); + 1 + } + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => { + s.clear_bindings(); + 1 + } + Some(Stmt::Sqlite(s)) => s.clear_bindings(), + Some(Stmt::Postgres(s)) => s.clear_bindings(), + Some(Stmt::Mysql(s)) => s.clear_bindings(), + None => 0, + } + }) +} + +/// Advances the statement one row: `1` for a row, `0` when exhausted, `-1` on +/// error — the same `-1` a caught panic degrades to (the client crates are the most +/// likely source of an unexpected panic, and this is where they run). +#[no_mangle] +pub extern "C" fn elephc_pdo_step(stmt_id: i64) -> i64 { + ffi_guard(-1, || { + let sqlite_statement = { + let mut guard = lock_recover(stmts()); + match guard.remove(&stmt_id) { + Some(Stmt::Sqlite(statement)) => Some(statement), + Some(other) => { + guard.insert(stmt_id, other); + None + } + None => None, + } + }; + if let Some(statement) = sqlite_statement { + let result = statement.step(); + lock_recover(stmts()).insert(stmt_id, Stmt::Sqlite(statement)); + return result; + } + let mut sguard = lock_recover(stmts()); + match sguard.get_mut(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => { + if s.needs_execute() { + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&s.conn_id) { + Some(Conn::Cubrid(connection)) => { + if s.execute(connection).is_err() { + return -1; + } + } + _ => return -1, + } + } + s.step() + } + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => { + if s.needs_execute() { + let conn_id = s.conn_id; + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&conn_id) { + Some(Conn::Dblib(c)) => { + if s.execute(c).is_err() { + return -1; + } + } + _ => return -1, + } + } + s.step() + } + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => { + if s.needs_execute() { + let conn_id = s.conn_id; + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&conn_id) { + Some(Conn::Firebird(c)) => { + if s.execute(c).is_err() { + return -1; + } + } + _ => return -1, + } + } + s.step() + } + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => { + if s.needs_execute() { + let conn_id = s.conn_id; + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&conn_id) { + Some(Conn::Odbc(c)) => { + if s.execute(c).is_err() { + return -1; + } + } + _ => return -1, + } + } + s.step() + } + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => { + if s.needs_execute() { + let conn_id = s.conn_id; + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&conn_id) { + Some(Conn::Oci(c)) => { + if s.execute(c).is_err() { + return -1; + } + } + _ => return -1, + } + } + s.step() + } + Some(Stmt::Postgres(s)) => { + let conn_id = s.conn_id; + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&conn_id) { + Some(Conn::Postgres(c)) => s.step(c), + _ => -1, + } + } + Some(Stmt::Mysql(s)) => { + let conn_id = s.conn_id; + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&conn_id) { + Some(Conn::Mysql(c)) => s.step(c), + _ => -1, + } + } + Some(Stmt::Sqlite(_)) | None => -1, + } + }) +} + +/// Moves a PostgreSQL result according to a PDO fetch orientation and offset. +/// Returns `1` for an active row, `0` when the target is outside the result or +/// for a non-PostgreSQL statement, and `-1` on execution failure or panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_step_oriented( + stmt_id: i64, + orientation: i64, + offset: i64, +) -> i64 { + ffi_guard(-1, || { + let mut sguard = lock_recover(stmts()); + match sguard.get_mut(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => { + if s.needs_execute() { + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&s.conn_id) { + Some(Conn::Cubrid(connection)) => { + if s.execute(connection).is_err() { + return -1; + } + } + _ => return -1, + } + } + s.step_oriented(orientation, offset) + } + Some(Stmt::Postgres(s)) => { + let conn_id = s.conn_id; + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&conn_id) { + Some(Conn::Postgres(c)) => s.step_oriented(c, orientation, offset), + _ => -1, + } + } + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(_)) => 0, + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(_)) => 0, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => { + if s.needs_execute() { + let conn_id = s.conn_id; + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&conn_id) { + Some(Conn::Odbc(c)) => { + if s.execute(c).is_err() { + return -1; + } + } + _ => return -1, + } + } + s.step_oriented(orientation, offset) + } + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => { + if s.needs_execute() { + let conn_id = s.conn_id; + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&conn_id) { + Some(Conn::Oci(c)) => { + if s.execute(c).is_err() { + return -1; + } + } + _ => return -1, + } + } + s.step_oriented(orientation, offset) + } + Some(Stmt::Sqlite(_)) | Some(Stmt::Mysql(_)) => 0, + None => -1, + } + }) +} + +/// Returns PDO_DBLIB's native DB-Library type identifier for one result column, +/// or `0` for another driver, an invalid handle/index, or a caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_dblib_column_native_type_id(stmt_id: i64, i: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "dblib")] + if let Some(Stmt::Dblib(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_native_type_id(i); + } + let _ = (stmt_id, i); + 0 + }) +} + +/// Returns PDO_DBLIB's native server user-type identifier for one result column, +/// or `0` for another driver, invalid input, or a caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_dblib_column_user_type_id(stmt_id: i64, i: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "dblib")] + if let Some(Stmt::Dblib(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_user_type_id(i); + } + let _ = (stmt_id, i); + 0 + }) +} + +/// Returns PDO_DBLIB's scale for one result column, or `0` for another driver, +/// invalid input, or a caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_dblib_column_scale(stmt_id: i64, i: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "dblib")] + if let Some(Stmt::Dblib(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_scale(i); + } + let _ = (stmt_id, i); + 0 + }) +} + +/// Returns PDO_DBLIB's source label for one result column. The pointer remains +/// valid until the next call to this accessor; unsupported/invalid input is empty. +#[no_mangle] +pub extern "C" fn elephc_pdo_dblib_column_source( + stmt_id: i64, + i: i64, +) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let source = { + #[cfg(feature = "dblib")] + if let Some(Stmt::Dblib(statement)) = lock_recover(stmts()).get(&stmt_id) { + return store_cstr(dblib_column_source_cell(), &statement.column_source(i)); + } + let _ = (stmt_id, i); + String::new() + }; + store_cstr(dblib_column_source_cell(), &source) + }) +} + +/// Returns the bytes owned by an executed PostgreSQL statement's materialized +/// result. `-1` means unexecuted/unknown/non-PostgreSQL and records php-src's +/// HY000 unexecuted-statement diagnostic on the owning connection. +#[no_mangle] +pub extern "C" fn elephc_pdo_result_memory_size(stmt_id: i64) -> i64 { + ffi_guard(-1, || { + let mut sguard = lock_recover(stmts()); + let Some(Stmt::Postgres(statement)) = sguard.get_mut(&stmt_id) else { + return -1; + }; + if let Some(bytes) = statement.result_memory_size() { + return bytes; + } + let conn_id = statement.conn_id; + if let Some(Conn::Postgres(connection)) = lock_recover(conns()).get_mut(&conn_id) { + connection.sqlstate = "HY000".to_string(); + connection.errcode = 0; + connection.errmsg = format!( + "statement '{}' has not been executed yet", + statement.query_string + ); + } + -1 + }) +} + +/// Advances a MySQL statement to its next materialized protocol result set. +/// Returns `1` when one became active and `0` for no further set, a non-MySQL +/// statement, an unknown handle, or a caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_next_rowset(stmt_id: i64) -> i64 { + ffi_guard(0, || { + let mut sguard = lock_recover(stmts()); + #[cfg(feature = "dblib")] + if let Some(Stmt::Dblib(statement)) = sguard.get_mut(&stmt_id) { + if !statement.next_rowset() { + return 0; + } + let conn_id = statement.conn_id; + let row_count = statement.current_row_count(); + if let Some(Conn::Dblib(connection)) = lock_recover(conns()).get_mut(&conn_id) { + connection.changes = row_count; + } + return 1; + } + #[cfg(feature = "firebird")] + if matches!(sguard.get(&stmt_id), Some(Stmt::Firebird(_))) { + return 0; + } + #[cfg(feature = "cubrid")] + if let Some(Stmt::Cubrid(statement)) = sguard.get_mut(&stmt_id) { + let mut cguard = lock_recover(conns()); + return match cguard.get_mut(&statement.conn_id) { + Some(Conn::Cubrid(connection)) => { + let advanced = statement.next_rowset(connection); + if advanced { + connection.changes = statement.row_count(); + } + advanced as i64 + } + _ => 0, + }; + } + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + if let Some(Stmt::Odbc(statement)) = sguard.get_mut(&stmt_id) { + let conn_id = statement.conn_id; + let mut cguard = lock_recover(conns()); + return match cguard.get_mut(&conn_id) { + Some(Conn::Odbc(connection)) => statement.next_rowset(connection) as i64, + _ => 0, + }; + } + let Some(Stmt::Mysql(statement)) = sguard.get_mut(&stmt_id) else { + return 0; + }; + let conn_id = statement.conn_id; + let mut cguard = lock_recover(conns()); + match cguard.get_mut(&conn_id) { + Some(Conn::Mysql(connection)) => statement.next_rowset(connection), + _ => 0, + } + }) +} + +/// Returns the number of result columns for the statement. Unknown handles — and a +/// caught panic — report `0`. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_count(stmt_id: i64) -> i64 { + ffi_guard(0, || { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.column_count(), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.column_count(), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.column_count(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_count(), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.column_count(), + Some(Stmt::Sqlite(s)) => s.column_count(), + Some(Stmt::Postgres(s)) => s.column_count(), + Some(Stmt::Mysql(s)) => s.column_count(), + None => 0, + } + }) +} + +/// Returns a pointer to the name of result column `i` (0-based). Unknown handles — +/// and a caught panic — report the empty string. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_name(stmt_id: i64, i: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let name = { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.column_name(i), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.column_name(i), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.column_name(i), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_name(i), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.column_name(i), + Some(Stmt::Sqlite(s)) => s.column_name(i), + Some(Stmt::Postgres(s)) => s.column_name(i), + Some(Stmt::Mysql(s)) => s.column_name(i), + None => String::new(), + } + }; + store_cstr(colname_cell(), &name) + }) +} + +/// Returns the SQLite-compatible type code for the current row's column `i` +/// (0-based): 1=int, 2=float, 3=text, 4=blob/bytea, 5=null. Unknown handles — and a +/// caught panic — report `5` ("null"), the type that carries no payload to read. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_type(stmt_id: i64, i: i64) -> i64 { + ffi_guard(5, || { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.column_type(i), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.column_type(i), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.column_type(i), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_type(i), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.column_type(i), + Some(Stmt::Sqlite(s)) => s.column_type(i), + Some(Stmt::Postgres(s)) => s.column_type(i), + Some(Stmt::Mysql(s)) => s.column_type(i), + None => 5, + } + }) +} + +/// Returns a pointer to the declared type of result column `i` (0-based) for a +/// SQLite statement (`sqlite3_column_decltype`), or an empty string for a +/// non-SQLite statement or an expression column. Feeds `getColumnMeta`'s +/// native_type. Valid until the next `elephc_pdo_column_decltype`. A caught panic +/// degrades to that same empty string. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_decltype(stmt_id: i64, i: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let decltype = { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + Some(Stmt::Sqlite(s)) => s.column_decltype(i), + _ => String::new(), + } + }; + store_cstr(decltype_cell(), &decltype) + }) +} + +/// Returns a pointer to the driver-native type name of result column `i` +/// (0-based), as that driver's own catalog spells it, for `getColumnMeta`'s +/// `native_type`: +/// - `pgsql:` — the server's `pg_type.typname` (`int4`, `bool`, `bytea`, …), +/// resolved from the column's `postgres::types::Type` at prepare time (P2-k). +/// - `mysql:` — the wire column type's MySQL name (`LONG`, `VAR_STRING`, `BIT`, +/// `NEWDECIMAL`, …), reproducing php-src's `type_to_name_native` switch +/// (`ext/pdo_mysql/mysql_statement.c:716-770`), whose +/// `PDO_MYSQL_NATIVE_TYPE_NAME(x)` macro stringifies the `MYSQL_TYPE_` suffix — +/// so an `INT` column is `LONG` and a `VARCHAR` is `VAR_STRING`, not the +/// friendlier SQL spelling (F-MY-08). +/// - `sqlite:` — deliberately empty. php-src's SQLite driver reports the column's +/// storage class, which the prelude already derives itself from the live value; +/// its declared type is a separate key, served by `elephc_pdo_column_decltype`. +/// +/// Empty for a SQLite statement, an unknown handle, an out-of-range index, or a +/// wire type php-src's own switch has no case for (its `default: return NULL`, +/// which makes php-src OMIT the key entirely — `mysql_statement.c:812-815`). The +/// prelude reads that empty string as "keep the generic storage-class metadata". +/// Valid until the next `elephc_pdo_column_native_type`. A caught panic degrades +/// to that same empty string, i.e. to the generic metadata path. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_native_type(stmt_id: i64, i: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let native = { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.column_native_type(i), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.column_native_type(i), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.column_native_type(i), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_native_type(i), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.column_native_type(i), + Some(Stmt::Postgres(s)) => s.column_native_type(i), + Some(Stmt::Mysql(s)) => s.column_native_type(i), + _ => String::new(), + } + }; + store_cstr(native_type_cell(), &native) + }) +} + +/// Returns the native source-table name for result column `i`, or an empty +/// string for expressions, unknown handles, and out-of-range columns. SQLite +/// and MySQL expose it directly in column metadata; PostgreSQL resolves the +/// RowDescription table OID through `pg_catalog.pg_class` at prepare time. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_table_name(stmt_id: i64, i: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let table = { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.column_table_name(i), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(_)) => String::new(), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(_)) => String::new(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_table_name(i), + #[cfg(feature = "oci")] + Some(Stmt::Oci(_)) => String::new(), + Some(Stmt::Sqlite(statement)) => statement.column_table_name(i), + Some(Stmt::Postgres(statement)) => statement.column_table_name(i), + Some(Stmt::Mysql(statement)) => statement.column_table_name(i), + None => String::new(), + } + }; + store_cstr(table_name_cell(), &table) + }) +} + +/// Returns driver-specific result-column flag bits for MySQL and Informix. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_flags(stmt_id: i64, i: i64) -> i64 { + ffi_guard(0, || match lock_recover(stmts()).get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(statement)) => statement.column_flags(i), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(statement)) => statement.column_flags(i), + Some(Stmt::Mysql(statement)) => statement.column_flags(i), + _ => 0, + }) +} + +/// Returns PDO_CUBRID's native column scale, or zero for another driver. +#[no_mangle] +pub extern "C" fn elephc_pdo_cubrid_column_scale(stmt_id: i64, column: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "cubrid")] + if let Some(Stmt::Cubrid(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_scale(column); + } + let _ = (stmt_id, column); + 0 + }) +} + +/// Returns PDO_CUBRID's native default-value metadata as a transient C string. +#[no_mangle] +pub extern "C" fn elephc_pdo_cubrid_column_default( + stmt_id: i64, + column: i64, +) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let value = { + #[cfg(feature = "cubrid")] + if let Some(Stmt::Cubrid(statement)) = lock_recover(stmts()).get(&stmt_id) { + statement.column_default(column) + } else { + String::new() + } + #[cfg(not(feature = "cubrid"))] + { + let _ = (stmt_id, column); + String::new() + } + }; + store_cstr(cubrid_column_default_cell(), &value) + }) +} + +/// Returns PDO_INFORMIX's `SQLDescribeCol` scale, or zero for another driver. +#[no_mangle] +pub extern "C" fn elephc_pdo_informix_column_scale(stmt_id: i64, column: i64) -> i64 { + ffi_guard(0, || { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_scale(column); + } + let _ = (stmt_id, column); + 0 + }) +} + +/// Returns PDO_INFORMIX's metadata `pdo_type`, defaulting to `PDO::PARAM_STR`. +#[no_mangle] +pub extern "C" fn elephc_pdo_informix_column_pdo_type(stmt_id: i64, column: i64) -> i64 { + ffi_guard(2, || { + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_pdo_type(column); + } + let _ = (stmt_id, column); + 2 + }) +} + +/// Returns PDO_IBM's `SQLDescribeCol` scale, or zero for another driver. +#[no_mangle] +pub extern "C" fn elephc_pdo_ibm_column_scale(stmt_id: i64, column: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "ibm")] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_scale(column); + } + let _ = (stmt_id, column); + 0 + }) +} + +/// Returns PDO_IBM's metadata `pdo_type`, including its upstream BOOLEAN fallthrough. +#[no_mangle] +pub extern "C" fn elephc_pdo_ibm_column_pdo_type(stmt_id: i64, column: i64) -> i64 { + ffi_guard(2, || { + #[cfg(feature = "ibm")] + if let Some(Stmt::Odbc(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.column_pdo_type(column); + } + let _ = (stmt_id, column); + 2 + }) +} + +/// Returns the PostgreSQL type OID of result column `i` (0-based) — the +/// `PQftype` value carried by the column's `postgres::types::Type`. `0` (the +/// invalid OID) for a non-PostgreSQL statement or an out-of-range index. The +/// prelude uses a non-zero value both as the "this is a pg column, describe it +/// natively" signal and to derive the PDO param type (BOOL→PARAM_BOOL, +/// int-family→PARAM_INT, BYTEA→PARAM_LOB, else PARAM_STR) plus the `pgsql:oid` +/// metadata key (P2-k). A caught panic degrades to that same `0`. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_type_oid(stmt_id: i64, i: i64) -> i64 { + ffi_guard(0, || { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.column_precision(i), + Some(Stmt::Postgres(s)) => s.column_type_oid(i), + _ => 0, + } + }) +} + +/// Returns the OID of the table result column `i` (0-based) was selected FROM on a +/// `pgsql:` statement — `PQftable()`. Backs `getColumnMeta`'s `pgsql:table_oid` +/// key, which php-src's `pgsql_stmt_get_column_meta` emits UNCONDITIONALLY, `0` +/// included (F-PG-01), so the prelude must emit the key even when this returns `0`. +/// +/// `0` is `InvalidOid`, and it is the server's OWN answer for a column that is not +/// a plain table column (an expression, a literal, an aggregate, a function +/// result) — so it doubles as the neutral value here for a non-PostgreSQL +/// statement, an unknown handle, an out-of-range index, and a caught panic. That +/// conflation is intentional and safe: a caller cannot distinguish "not a table +/// column" from "not a pg statement", but neither can real PDO, which only emits +/// the key for a pg statement in the first place. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_table_oid(stmt_id: i64, i: i64) -> i64 { + ffi_guard(0, || { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + Some(Stmt::Postgres(s)) => s.column_table_oid(i), + _ => 0, + } + }) +} + +/// Returns the byte width of result column `i`'s type (0-based) on a `pgsql:` +/// statement: a positive fixed width (`int4` → 4, `timestamp` → 8, `uuid` → 16), +/// or `-1` for a variable-length (varlena) type — `text`, `varchar`, `numeric`, +/// `bytea`, `json`, any array. Backs `getColumnMeta`'s `len`, which php-src fills +/// from `PQfsize()` (`ext/pdo_pgsql/pgsql_statement.c:496`) (F-PG-02). +/// +/// Note that a varlena's `len` is `-1`, NOT its declared `n`: `VARCHAR(20)` reports +/// `-1` here and surfaces its 20 through `elephc_pdo_column_precision` instead (as +/// 24, the raw `atttypmod`). That is real PDO's behavior, not an approximation. +/// +/// The value is DERIVED from the column's type rather than read off the wire — +/// tokio-postgres parses the RowDescription's data-type-size field but drops it +/// when building `Column`. This is sound because `PQfsize()` returns +/// `pg_type.typlen`, a property of the TYPE and not of the column or row; see +/// [`pg::PgStmt::column_len`] for the derivation table and its documented edges. +/// +/// `-1` — PostgreSQL's own "not a fixed width" — for a non-PostgreSQL statement, an +/// unknown handle, an out-of-range index, and a caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_len(stmt_id: i64, i: i64) -> i64 { + ffi_guard(-1, || { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.column_len(i), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(_)) => -1, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_len(i), + Some(Stmt::Postgres(s)) => s.column_len(i), + Some(Stmt::Mysql(s)) => s.column_len(i), + _ => -1, + } + }) +} + +/// Returns the type modifier (`atttypmod`) of result column `i` (0-based) on a +/// `pgsql:` statement — `PQfmod()`. Backs `getColumnMeta`'s `precision`, which +/// php-src fills from it straight (`ext/pdo_pgsql/pgsql_statement.c:497`) (F-PG-02). +/// +/// The value is the RAW modifier, deliberately NOT decoded, because php-src does +/// not decode it either: `VARCHAR(20)` reports 24 (the length plus `VARHDRSZ` = 4) +/// and `NUMERIC(10,2)` reports 655366 (`((10 << 16) | 2) + 4`). Decoding it into a +/// human-readable precision here would be a divergence from PHP dressed up as an +/// improvement — a caller who wants the real precision must decode the modifier +/// exactly as it would have to against real PDO. +/// +/// `-1` — PostgreSQL's own "no type modifier" — for a type that takes no modifier +/// or a column carrying none, and equally for a non-PostgreSQL statement, an +/// unknown handle, an out-of-range index, and a caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_precision(stmt_id: i64, i: i64) -> i64 { + ffi_guard(-1, || { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.column_precision(i), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(_)) => -1, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_precision(i), + Some(Stmt::Postgres(s)) => s.column_precision(i), + Some(Stmt::Mysql(s)) => s.column_precision(i), + Some(Stmt::Sqlite(_)) => 0, + _ => -1, + } + }) +} + +/// Loads a SQLite extension by path for a `sqlite:` connection +/// (`Pdo\Sqlite::loadExtension()`), returning 1 on success or 0 for a +/// non-SQLite connection, unknown handle, load error, or a caught panic. +/// +/// # Safety +/// `path` must point to a NUL-terminated string valid for the call, and loading an +/// extension runs arbitrary native code from it. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_load_extension(conn_id: i64, path: *const c_char) -> i64 { + ffi_guard(0, || { + let Some(path) = cstr_arg(path) else { + return 0; + }; + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + Some(Conn::Sqlite(c)) => c.load_extension(path), + _ => 0, + } + }) +} + +/// Registers a custom SQLite collation from a compiled-PHP comparator +/// (`Pdo\Sqlite::createCollation`). `descriptor` is the callable descriptor +/// pointer and `adapter` the codegen collation-adapter address, both produced by +/// the prelude via `__elephc_callable_ptr` / `__elephc_pdo_adapter_addr`. Returns +/// `1` on success, `0` on error, a non-SQLite handle, or a caught panic. Registration +/// itself never fires the comparator (SQLite invokes it later, during an +/// `ORDER BY … COLLATE`), so the connection lock is held only for the brief +/// `sqlite3_create_collation_v2`. +/// +/// # Safety +/// `name` must be a NUL-terminated string valid for the call; `descriptor`/`adapter` +/// must be the live callable descriptor and adapter entry of the compiled program. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_create_collation( + conn_id: i64, + name: *const c_char, + descriptor: *mut c_void, + adapter: *mut c_void, +) -> i64 { + ffi_guard(0, || { + let Some(name) = cstr_arg(name) else { + return 0; + }; + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + Some(Conn::Sqlite(c)) => c.create_collation(name, descriptor, adapter as *const c_void), + _ => 0, + } + }) +} + +/// Registers a scalar SQL function `name` backed by a compiled-PHP callable +/// (`Pdo\Sqlite::createFunction`). `num_args` is the declared arity (-1 = variadic), +/// `flags` an optional `SQLITE_DETERMINISTIC`, and `descriptor`/`adapter` the callable +/// descriptor pointer and the codegen scalar adapter address, produced by the prelude +/// via `__elephc_callable_ptr` / `__elephc_pdo_adapter_addr`. Returns `1` on success, +/// `0` on error, a non-SQLite handle, or a caught panic. +/// +/// # Safety +/// `name` must be a NUL-terminated string valid for the call; `descriptor`/`adapter` +/// must be the live callable descriptor and adapter entry of the compiled program. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_create_function( + conn_id: i64, + name: *const c_char, + num_args: i64, + flags: i64, + descriptor: *mut c_void, + adapter: *mut c_void, +) -> i64 { + ffi_guard(0, || { + let Some(name) = cstr_arg(name) else { + return 0; + }; + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + Some(Conn::Sqlite(c)) => { + c.create_function(name, num_args, flags, descriptor, adapter as *const c_void) + } + _ => 0, + } + }) +} + +/// Registers an aggregate SQL function `name` backed by a compiled-PHP step + +/// finalize pair (`Pdo\Sqlite::createAggregate`). `num_args` is the declared arity +/// (-1 = variadic); each callable crosses as a (descriptor, adapter) pointer pair, +/// produced by the prelude via `__elephc_callable_ptr` / `__elephc_pdo_adapter_addr` +/// (kinds 2 and 3). Returns `1` on success, `0` on error, a non-SQLite handle, or a +/// caught panic. +/// +/// # Safety +/// `name` must be a NUL-terminated string valid for the call; the four pointers must +/// be the live callable descriptors and adapter entries of the compiled program. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_create_aggregate( + conn_id: i64, + name: *const c_char, + num_args: i64, + step_descriptor: *mut c_void, + step_adapter: *mut c_void, + final_descriptor: *mut c_void, + final_adapter: *mut c_void, +) -> i64 { + ffi_guard(0, || { + let Some(name) = cstr_arg(name) else { + return 0; + }; + let guard = lock_recover(conns()); + match guard.get(&conn_id) { + Some(Conn::Sqlite(c)) => c.create_aggregate( + name, + num_args, + step_descriptor, + step_adapter as *const c_void, + final_descriptor, + final_adapter as *const c_void, + ), + _ => 0, + } + }) +} + +/// Installs a PHP 8.5 SQLite authorizer callback. The callable descriptor and +/// shared scalar-adapter address are produced by the PDO prelude. Returns `1` on +/// success, `0` for a non-SQLite/unknown connection or a caught panic. +/// +/// # Safety +/// `descriptor` and `adapter` must be live compiled-program pointers rooted by +/// the owning PDO object until reset or connection close. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_set_authorizer( + conn_id: i64, + descriptor: *mut c_void, + adapter: *mut c_void, +) -> i64 { + ffi_guard(0, || match lock_recover(conns()).get(&conn_id) { + Some(Conn::Sqlite(c)) => c.set_authorizer(descriptor, adapter as *const c_void), + _ => 0, + }) +} + +/// Clears a PHP 8.5 SQLite authorizer registration. Returns `1` for a live +/// SQLite connection (including an already-cleared one), otherwise `0`. +#[no_mangle] +pub extern "C" fn elephc_pdo_clear_authorizer(conn_id: i64) -> i64 { + ffi_guard(0, || match lock_recover(conns()).get(&conn_id) { + Some(Conn::Sqlite(c)) => { + c.clear_authorizer(); + 1 + } + _ => 0, + }) +} + +/// Clears every SQLite collation, scalar, aggregate, and authorizer callback tied +/// to a connection. Returns `1` for a live SQLite connection and `0` otherwise. +#[no_mangle] +pub extern "C" fn elephc_pdo_clear_callbacks(conn_id: i64) -> i64 { + ffi_guard(0, || { + let sqlite_db = match lock_recover(conns()).get(&conn_id) { + Some(Conn::Sqlite(c)) => c.db, + _ => return 0, + }; + // SQLite can refuse to delete a function while a prepared statement still + // references it. PDO teardown already invalidates statements belonging to + // the destroyed handle, so finalize those registrations before callbacks. + let owned: Vec = lock_recover(stmts()) + .iter() + .filter_map(|(id, stmt)| match stmt { + Stmt::Sqlite(stmt) if stmt.db == sqlite_db => Some(*id), + _ => None, + }) + .collect(); + { + let mut statements = lock_recover(stmts()); + for id in owned { + if let Some(Stmt::Sqlite(stmt)) = statements.get(&id) { + stmt.finalize(); + } + statements.remove(&id); + } + } + match lock_recover(conns()).get(&conn_id) { + Some(Conn::Sqlite(c)) => { + c.clear_callbacks(); + 1 + } + _ => 0, + } + }) +} + +/// Takes and clears the deferred PHP error classification from the most recent +/// SQLite authorizer callback. Zero means no callback contract error. +#[no_mangle] +pub extern "C" fn elephc_pdo_take_authorizer_error(conn_id: i64) -> i64 { + ffi_guard(0, || match lock_recover(conns()).get(&conn_id) { + Some(Conn::Sqlite(c)) => c.take_authorizer_error(), + _ => 0, + }) +} + +/// Returns the current row's column `i` (0-based) as an integer. Unknown handles — +/// and a caught panic — report `0`. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_int(stmt_id: i64, i: i64) -> i64 { + ffi_guard(0, || { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.column_int(i), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.column_int(i), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.column_int(i), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_int(i), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.column_int(i), + Some(Stmt::Sqlite(s)) => s.column_int(i), + Some(Stmt::Postgres(s)) => s.column_int(i), + Some(Stmt::Mysql(s)) => s.column_int(i), + None => 0, + } + }) +} + +/// Returns the current row's column `i` (0-based) as a double. Unknown handles — and +/// a caught panic — report `0.0`. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_double(stmt_id: i64, i: i64) -> f64 { + ffi_guard(0.0, || { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.column_double(i), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.column_double(i), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.column_double(i), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_double(i), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.column_double(i), + Some(Stmt::Sqlite(s)) => s.column_double(i), + Some(Stmt::Postgres(s)) => s.column_double(i), + Some(Stmt::Mysql(s)) => s.column_double(i), + None => 0.0, + } + }) +} + +/// Returns the byte length of the current row's column `i` rendered as PDO text +/// or BLOB bytes. Paired with `elephc_pdo_column_data_ptr`, this is the only column +/// read path: it is byte-exact, so embedded NUL bytes survive (v24/F-QUAL-03 deleted +/// the NUL-stripping `elephc_pdo_column_text` that used to sit beside it). Unknown +/// handles — and a caught panic — report `0`. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_data_len(stmt_id: i64, i: i64) -> i64 { + ffi_guard(0, || { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.column_data(i).len() as i64, + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.column_data(i).len() as i64, + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.column_data(i).len() as i64, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_data(i).len() as i64, + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.column_data(i).len() as i64, + Some(Stmt::Sqlite(s)) => s.column_data(i).len() as i64, + Some(Stmt::Postgres(s)) => s.column_data(i).len() as i64, + Some(Stmt::Mysql(s)) => s.column_data(i).len() as i64, + None => 0, + } + }) +} + +/// Returns a pointer to the current row's column `i` rendered as raw bytes. +/// The pointer remains valid until the next `elephc_pdo_column_data_ptr` call on the +/// current thread. An empty column — and a caught panic — report a NULL pointer, +/// which the prelude reads as the empty string (it pairs every read with +/// `elephc_pdo_column_data_len`). Calls on another thread use an independent buffer. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_data_ptr(stmt_id: i64, i: i64) -> *const c_char { + ffi_guard(std::ptr::null(), || { + let bytes = { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.column_data(i), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.column_data(i), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.column_data(i), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_data(i), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.column_data(i), + Some(Stmt::Sqlite(s)) => s.column_data(i), + Some(Stmt::Postgres(s)) => s.column_data(i), + Some(Stmt::Mysql(s)) => s.column_data(i), + None => Vec::new(), + } + }; + store_bytes(bytes) + }) +} + +/// Returns one byte from the current row's column `i` rendered as raw data. +/// Out-of-range handles, columns, and offsets return `0` — and so does a caught panic. +#[no_mangle] +pub extern "C" fn elephc_pdo_column_data_byte(stmt_id: i64, i: i64, offset: i64) -> i64 { + ffi_guard(0, || { + let Ok(offset) = usize::try_from(offset) else { + return 0; + }; + let bytes = { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.column_data(i), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.column_data(i), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.column_data(i), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.column_data(i), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.column_data(i), + Some(Stmt::Sqlite(s)) => s.column_data(i), + Some(Stmt::Postgres(s)) => s.column_data(i), + Some(Stmt::Mysql(s)) => s.column_data(i), + None => Vec::new(), + } + }; + bytes.get(offset).copied().unwrap_or(0) as i64 + }) +} + +/// Finalizes a statement and removes it from the table. Unknown handles return +/// `0`; success returns `1`. A caught panic reports `0` — the statement may then stay +/// registered, which leaks it but keeps the handle table consistent. +#[no_mangle] +pub extern "C" fn elephc_pdo_finalize(stmt_id: i64) -> i64 { + ffi_guard(0, || { + match lock_recover(stmts()).remove(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(_)) => 1, + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(_)) => 1, + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(_)) => 1, + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(_)) => 1, + #[cfg(feature = "oci")] + Some(Stmt::Oci(_)) => 1, + Some(Stmt::Sqlite(s)) => { + s.finalize(); + 1 + } + Some(Stmt::Postgres(mut statement)) => { + if let Some(Conn::Postgres(connection)) = + lock_recover(conns()).get_mut(&statement.conn_id) + { + statement.reset(connection); + } + 1 + } + Some(Stmt::Mysql(mut statement)) => { + if let Some(Conn::Mysql(connection)) = + lock_recover(conns()).get_mut(&statement.conn_id) + { + statement.reset(connection); + } + 1 + } + None => 0, + } + }) +} + +/// Returns `1` if a SQLite statement makes no direct changes to the database file +/// content (`sqlite3_stmt_readonly`), else `0` — including for a non-SQLite or +/// unknown handle, where the notion does not apply. Backs +/// `PDOStatement::getAttribute(Pdo\Sqlite::ATTR_READONLY_STATEMENT)` (P2-16) as a +/// live read rather than a value stored at prepare time. A caught panic degrades to +/// that same `0`. +#[no_mangle] +pub extern "C" fn elephc_pdo_stmt_readonly(stmt_id: i64) -> i64 { + ffi_guard(0, || { + match lock_recover(stmts()).get(&stmt_id) { + Some(Stmt::Sqlite(s)) => s.readonly(), + _ => 0, + } + }) +} + +/// Returns SQLite's live busy flag for a statement, or `0` for another driver/handle. +#[no_mangle] +pub extern "C" fn elephc_pdo_stmt_busy(stmt_id: i64) -> i64 { + ffi_guard(0, || match lock_recover(stmts()).get(&stmt_id) { + Some(Stmt::Sqlite(statement)) => statement.busy(), + _ => 0, + }) +} + +/// Returns SQLite's explain mode for a statement, or `-1` for another driver/handle. +#[no_mangle] +pub extern "C" fn elephc_pdo_stmt_explain_mode(stmt_id: i64) -> i64 { + ffi_guard(-1, || match lock_recover(stmts()).get(&stmt_id) { + Some(Stmt::Sqlite(statement)) => statement.explain_mode(), + _ => -1, + }) +} + +/// Sets SQLite's explain mode for a statement, returning `1` only on success. +#[no_mangle] +pub extern "C" fn elephc_pdo_stmt_set_explain_mode(stmt_id: i64, mode: i64) -> i64 { + ffi_guard(0, || match lock_recover(stmts()).get(&stmt_id) { + Some(Stmt::Sqlite(statement)) => statement.set_explain_mode(mode), + _ => 0, + }) +} + +/// Returns the native driver code for the statement's last operation. SQLite +/// tracks this per-connection (mirrored here from the statement's own `db` +/// pointer); PostgreSQL/MySQL statements share their connection's bookkeeping +/// (looked up by the statement's `conn_id`, the same way `elephc_pdo_step` +/// dispatches into the connection to execute). Unknown handles — and a caught panic +/// — return `-1`. +#[no_mangle] +pub extern "C" fn elephc_pdo_stmt_errcode(stmt_id: i64) -> i64 { + ffi_guard(-1, || { + let sguard = lock_recover(stmts()); + match sguard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.errcode(), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.errcode(), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.errcode(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.errcode(), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.errcode(), + Some(Stmt::Sqlite(s)) => s.errcode(), + Some(Stmt::Postgres(s)) => { + let conn_id = s.conn_id; + let cguard = lock_recover(conns()); + match cguard.get(&conn_id) { + Some(Conn::Postgres(c)) => c.errcode, + _ => -1, + } + } + Some(Stmt::Mysql(s)) => { + let conn_id = s.conn_id; + let cguard = lock_recover(conns()); + match cguard.get(&conn_id) { + Some(Conn::Mysql(c)) => c.errcode, + _ => -1, + } + } + None => -1, + } + }) +} + +/// Returns a pointer to the statement's last error message (see +/// `elephc_pdo_stmt_errcode` for how PostgreSQL/MySQL statements share their +/// connection's bookkeeping). Empty string for an unknown handle — and for a caught +/// panic. Valid until the next `elephc_pdo_stmt_errmsg`. +#[no_mangle] +pub extern "C" fn elephc_pdo_stmt_errmsg(stmt_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let msg = { + let sguard = lock_recover(stmts()); + match sguard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.errmsg().to_string(), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.errmsg().to_string(), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.errmsg().to_string(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.errmsg().to_string(), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.errmsg().to_string(), + Some(Stmt::Sqlite(s)) => s.errmsg(), + Some(Stmt::Postgres(s)) => { + let conn_id = s.conn_id; + let cguard = lock_recover(conns()); + match cguard.get(&conn_id) { + Some(Conn::Postgres(c)) => c.errmsg.clone(), + _ => String::new(), + } + } + Some(Stmt::Mysql(s)) => { + let conn_id = s.conn_id; + let cguard = lock_recover(conns()); + match cguard.get(&conn_id) { + Some(Conn::Mysql(c)) => c.errmsg.clone(), + _ => String::new(), + } + } + None => String::new(), + } + }; + store_cstr(stmt_errmsg_cell(), &msg) + }) +} + +/// Returns PDO_DBLIB's operating-system error code for statement errorInfo. +#[no_mangle] +pub extern "C" fn elephc_pdo_dblib_stmt_os_errcode(stmt_id: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "dblib")] + if let Some(Stmt::Dblib(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.os_errcode(); + } + let _ = stmt_id; + 0 + }) +} + +/// Returns PDO_DBLIB's error severity for statement errorInfo. +#[no_mangle] +pub extern "C" fn elephc_pdo_dblib_stmt_severity(stmt_id: i64) -> i64 { + ffi_guard(0, || { + #[cfg(feature = "dblib")] + if let Some(Stmt::Dblib(statement)) = lock_recover(stmts()).get(&stmt_id) { + return statement.severity(); + } + let _ = stmt_id; + 0 + }) +} + +/// Returns PDO_DBLIB's operating-system statement diagnostic. The pointer remains +/// valid until the next call; unsupported or unknown handles return empty. +#[no_mangle] +pub extern "C" fn elephc_pdo_dblib_stmt_os_errmsg(stmt_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let message = { + let guard = lock_recover(stmts()); + match guard.get(&stmt_id) { + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(statement)) => statement.os_errmsg().to_string(), + _ => String::new(), + } + }; + store_cstr(dblib_stmt_os_errmsg_cell(), &message) + }) +} + +/// Returns the most recently rendered SQL for an emulated MySQL/PostgreSQL +/// statement. Native and SQLite statements, unknown handles and caught panics return +/// an empty string. Valid until the next `elephc_pdo_stmt_sent_sql` call. +#[no_mangle] +pub extern "C" fn elephc_pdo_stmt_sent_sql(stmt_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"\0"), || { + let sql = { + let sguard = lock_recover(stmts()); + match sguard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(_)) => String::new(), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.sent_sql.clone(), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.sent_sql.clone(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.sent_sql.clone(), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.sent_sql.clone(), + Some(Stmt::Postgres(s)) => s.sent_sql.clone(), + Some(Stmt::Mysql(s)) => s.sent_sql.clone(), + Some(Stmt::Sqlite(_)) | None => String::new(), + } + }; + store_cstr(stmt_sent_sql_cell(), &sql) + }) +} + +/// Returns a pointer to the 5-char SQLSTATE for the statement's last operation +/// (see `elephc_pdo_stmt_errcode` for how PostgreSQL/MySQL statements share their +/// connection's bookkeeping). Unknown handles report `"00000"`; a statement whose +/// connection has since closed reports `"HY000"`. A caught panic degrades to the +/// unknown-handle answer, `"00000"` — the call that panicked has already reported its +/// own failure sentinel, which is what the prelude raises on. Valid until the next +/// `elephc_pdo_stmt_sqlstate`. +#[no_mangle] +pub extern "C" fn elephc_pdo_stmt_sqlstate(stmt_id: i64) -> *const c_char { + ffi_guard(static_cstr(b"00000\0"), || { + let state = { + let sguard = lock_recover(stmts()); + match sguard.get(&stmt_id) { + #[cfg(feature = "cubrid")] + Some(Stmt::Cubrid(s)) => s.sqlstate().to_string(), + #[cfg(feature = "dblib")] + Some(Stmt::Dblib(s)) => s.sqlstate().to_string(), + #[cfg(feature = "firebird")] + Some(Stmt::Firebird(s)) => s.sqlstate().to_string(), + #[cfg(any(feature = "odbc", feature = "informix", feature = "ibm", feature = "sqlsrv"))] + Some(Stmt::Odbc(s)) => s.sqlstate().to_string(), + #[cfg(feature = "oci")] + Some(Stmt::Oci(s)) => s.sqlstate().to_string(), + Some(Stmt::Sqlite(s)) => s.sqlstate(), + Some(Stmt::Postgres(s)) => { + let conn_id = s.conn_id; + let cguard = lock_recover(conns()); + match cguard.get(&conn_id) { + Some(Conn::Postgres(c)) => c.sqlstate.clone(), + _ => "HY000".to_string(), + } + } + Some(Stmt::Mysql(s)) => { + let conn_id = s.conn_id; + let cguard = lock_recover(conns()); + match cguard.get(&conn_id) { + Some(Conn::Mysql(c)) => c.sqlstate.clone(), + _ => "HY000".to_string(), + } + } + None => "00000".to_string(), + } + }; + store_cstr(stmt_sqlstate_cell(), &state) + }) +} + +#[cfg(test)] +mod tests { + //! Unit tests for the PDO bridge, plus the two `#[ignore]` live round-trips + //! (`pg_round_trip`, `my_round_trip`) that need a real server. + //! + //! Live-server environment variables (F-QUAL-06) + //! --------------------------------------------- + //! The live tests are split across two suites that historically read DISJOINT + //! variable families, so exporting one family silently left the other suite's + //! `#[ignore]` tests doing nothing: + //! + //! - `ELEPHC_PG_DSN` — PostgreSQL DSN. Read by the codegen suite + //! (`tests/codegen/pdo_pgsql.rs`) and, as a FALLBACK, by `pg_round_trip` here. + //! - `ELEPHC_MY_DSN` — MySQL/MariaDB DSN. Read by the codegen suite + //! (`tests/codegen/pdo_mysql.rs`) and, as a FALLBACK, by `my_round_trip` here. + //! - `ELEPHC_PG_TEST_DSN` — legacy in-crate-only name for the PostgreSQL DSN. + //! Still honored, and still takes precedence over `ELEPHC_PG_DSN`. + //! - `ELEPHC_MY_TEST_DSN` — legacy in-crate-only name for the MySQL DSN. + //! Still honored, and still takes precedence over `ELEPHC_MY_DSN`. + //! - `ELEPHC_PG_TLS_DSN` — codegen-only: a `sslmode=require` PostgreSQL DSN for + //! `pgsql_tls_round_trip`. Needs a TLS-serving server. + //! - `ELEPHC_MY_TLS_DSN` / `ELEPHC_MY_TLS_CA` — codegen-only: DSN + CA-bundle path + //! for `mysql_tls_round_trip`. Needs a TLS-serving server; the default build + //! already includes the ring-backed `mysql-tls` feature. + //! + //! Because of the fallback, exporting just `ELEPHC_PG_DSN` + `ELEPHC_MY_DSN` now + //! drives BOTH suites: + //! + //! ```text + //! ELEPHC_PG_DSN='pgsql:host=localhost;port=5432;dbname=testdb;user=test;password=test' \ + //! ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=3306;dbname=testdb;user=test;password=test' \ + //! cargo test -p elephc-pdo -- --ignored + //! ``` + //! + //! - `ELEPHC_PDO_LIVE_REQUIRED` — set to `1` (as the nightly `pdo-live.yml` workflow + //! does) to turn "no DSN in the environment" from a SILENT early return into a + //! panic. Without it, a renamed or unexported variable makes a live test that ran + //! nothing look exactly like a live test that passed — the failure mode that let + //! the `CALL` row-drop regression through. + + use super::*; + + /// Resolves a live-server DSN from the first environment variable that is set, + /// preferring the legacy in-crate name and falling back to the name the codegen + /// live suite uses (F-QUAL-06), so one set of variables runs every live test. + /// + /// Returns `None` — and the caller skips — when neither name is set, unless + /// `ELEPHC_PDO_LIVE_REQUIRED=1`, in which case the missing DSN is a hard panic + /// so a misconfigured CI job cannot report a green live run that executed nothing. + fn live_dsn(primary: &str, fallback: &str) -> Option { + let dsn = std::env::var(primary) + .ok() + .or_else(|| std::env::var(fallback).ok()) + .filter(|dsn| !dsn.trim().is_empty()); + if dsn.is_none() && std::env::var("ELEPHC_PDO_LIVE_REQUIRED").as_deref() == Ok("1") { + panic!( + "ELEPHC_PDO_LIVE_REQUIRED=1 but neither {} nor {} is set: this live test \ + would have silently skipped", + primary, fallback + ); + } + dsn + } + + /// Reads a `*const c_char` bridge string return into an owned `String`. + unsafe fn read(p: *const c_char) -> String { + if p.is_null() { + return String::new(); + } + CStr::from_ptr(p).to_string_lossy().into_owned() + } + + /// Builds an owned NUL-terminated C string for the extern-shaped string args. + fn cs(s: &str) -> CString { + CString::new(s).unwrap() + } + + /// Reads a bridge raw-data pointer and length into owned bytes. + unsafe fn read_bytes(p: *const c_char, len: i64) -> Vec { + if p.is_null() || len <= 0 { + return Vec::new(); + } + std::slice::from_raw_parts(p as *const u8, len as usize).to_vec() + } + + /// Concurrent column-data results retain their own bytes until the caller on + /// each thread has copied them out of the bridge-owned return buffer. + #[test] + fn column_data_buffers_are_isolated_between_threads() { + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let handles = [b"Ada".to_vec(), b"Grace".to_vec()].map(|expected| { + let barrier = std::sync::Arc::clone(&barrier); + std::thread::spawn(move || { + let pointer = store_bytes(expected.clone()); + barrier.wait(); + let actual = unsafe { read_bytes(pointer, expected.len() as i64) }; + assert_eq!(actual, expected); + }) + }); + + for handle in handles { + handle.join().expect("column-data worker must not panic"); + } + } + + /// The ABI version constant tracks the current bridge surface; the per-version + /// history is enumerated on `elephc_pdo_version`'s own docblock. v57 exposes + /// PDO_SQLSRV and its driver-specific statement/configuration surface on top of v56. + #[test] + fn version_is_v57() { + assert_eq!(elephc_pdo_version(), 57); + } + + /// Connection-information accessors return empty strings for unknown handles. + #[test] + fn connection_information_is_empty_for_unknown_handle() { + assert_eq!(unsafe { read(elephc_pdo_client_version(-999)) }, ""); + assert_eq!(unsafe { read(elephc_pdo_server_info(-999)) }, ""); + assert_eq!(unsafe { read(elephc_pdo_connection_status(-999)) }, ""); + } + + /// The rendered-SQL accessor is empty for an unknown statement handle. + #[test] + fn sent_sql_is_empty_for_unknown_stmt() { + assert_eq!(unsafe { read(elephc_pdo_stmt_sent_sql(-999)) }, ""); + } + + /// The two v23 metadata accessors return their neutral "not a PostgreSQL + /// column" answers for an unknown statement handle: an empty native type and + /// the invalid OID `0`. (A real `pgsql:` column's non-empty name / non-zero + /// OID is exercised by the live `#[ignore]` codegen tests, since a prepared + /// PostgreSQL statement is needed to populate the column descriptor.) + #[test] + fn column_metadata_accessors_neutral_for_unknown_stmt() { + let native = elephc_pdo_column_native_type(-999, 0); + assert!( + unsafe { std::ffi::CStr::from_ptr(native) } + .to_bytes() + .is_empty(), + "native type for an unknown statement handle must be empty", + ); + assert_eq!( + elephc_pdo_column_type_oid(-999, 0), + 0, + "type OID for an unknown statement handle must be the invalid OID 0", + ); + } + + /// `elephc_pdo_in_transaction` reads SQLite's live autocommit state through + /// the full C ABI: `0` before any `BEGIN`, `1` once `elephc_pdo_begin` starts + /// one, `0` again after `elephc_pdo_commit` — the same live signal the + /// codegen test `test_pdo_in_transaction_reflects_raw_begin` exercises at the + /// PHP level via a raw `exec("BEGIN")` instead of `beginTransaction()`. + #[test] + fn sqlite_in_transaction_reflects_live_autocommit_state() { + let dsn = cs("sqlite::memory:"); + let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; + assert!(conn > 0, "open failed"); + assert_eq!(elephc_pdo_in_transaction(conn), 0, "no transaction yet"); + assert_eq!(elephc_pdo_begin(conn), 1); + assert_eq!(elephc_pdo_in_transaction(conn), 1, "BEGIN must be visible live"); + assert_eq!(elephc_pdo_commit(conn), 1); + assert_eq!(elephc_pdo_in_transaction(conn), 0, "COMMIT must clear it live"); + elephc_pdo_close(conn); + } + + /// `elephc_pdo_in_transaction` reports `-1` ("unknown, use the caller's own + /// flag") for a handle this bridge has never seen. + #[test] + fn in_transaction_unknown_handle_is_negative_one() { + assert_eq!(elephc_pdo_in_transaction(-12345), -1); + } + + /// MySQL and PostgreSQL transaction classifiers expose raw PDO::exec control + /// statements while preserving savepoint and chained-transaction semantics. + #[test] + fn external_driver_transaction_state_tracks_control_sql() { + assert!(my::transaction_state_after_sql("BEGIN", false, true)); + assert!(my::transaction_state_after_sql("ROLLBACK TO SAVEPOINT s", true, true)); + assert!(!my::transaction_state_after_sql("COMMIT", true, true)); + assert!(my::transaction_state_after_sql("INSERT INTO t VALUES (1)", false, false)); + assert!(!my::transaction_state_after_sql("CREATE TABLE t (n INT)", true, false)); + + assert!(pg::transaction_state_after_sql("START TRANSACTION", false)); + assert!(pg::transaction_state_after_sql("ROLLBACK TO s", true)); + assert!(pg::transaction_state_after_sql("COMMIT AND CHAIN", true)); + assert!(!pg::transaction_state_after_sql("ROLLBACK", true)); + } + + /// A DSN for an unsupported driver is rejected with a driver error. + #[test] + fn open_rejects_unknown_driver_dsn() { + let dsn = cs("oracle:host=localhost"); + let id = unsafe { elephc_pdo_open(dsn.as_ptr()) }; + assert_eq!(id, -1); + let msg = unsafe { read(elephc_pdo_last_open_error()) }; + assert!(msg.contains("driver"), "got: {msg}"); + } + + /// Unknown handles return the documented sentinels rather than panicking. + #[test] + fn unknown_handles_return_sentinels() { + assert_eq!(elephc_pdo_step(999_999), -1); + assert_eq!(elephc_pdo_column_count(999_999), 0); + assert_eq!(elephc_pdo_finalize(999_999), 0); + } + + /// F-QUAL-02, half 1 (`ffi_guard`): the bridge's entry points are plain + /// `extern "C"`, not `extern "C-unwind"`, so on rustc ≥ 1.81 a panic escaping one + /// of them ABORTS the whole compiled PHP process — no catchable `PDOException`, + /// no stack trace, just a dead program. A panic is not hypothetical here: an + /// internal `unwrap`, a debug-build overflow, or an unexpected panic inside the + /// `postgres`/`mysql` client crates all reach the boundary. `ffi_guard` converts + /// any panic into the SAME "failed" answer each entry point's docblock already + /// promises for an unknown handle (`-1`/`0`/`"00000"`/…), which the prelude turns + /// into a normal error. Pinned here directly, since forcing a real panic inside a + /// specific extern is not reproducible from a test. + #[test] + fn ffi_guard_converts_a_panic_into_the_documented_sentinel() { + let minus_one = ffi_guard(-1_i64, || -> i64 { + panic!("deliberate panic (F-QUAL-02 test) — expected"); + }); + assert_eq!(minus_one, -1); + let zero = ffi_guard(0_i64, || -> i64 { + panic!("deliberate panic (F-QUAL-02 test) — expected"); + }); + assert_eq!(zero, 0); + // The happy path must still return the body's value untouched. + assert_eq!(ffi_guard(-1_i64, || -> i64 { 7 }), 7); + // The `*const c_char` entry points hand back a readable `'static` C string in + // the panic path rather than a dangling/NULL pointer the prelude would deref. + let state = ffi_guard(static_cstr(b"00000\0"), || -> *const c_char { + panic!("deliberate panic (F-QUAL-02 test) — expected"); + }); + assert_eq!(unsafe { read(state) }, "00000"); + } + + /// F-QUAL-02, half 2 (`lock_recover`): a panic caught by [`ffi_guard`] while a + /// handle-table lock was held leaves that `Mutex` POISONED for the life of the + /// process. With the old `.lock().unwrap()` at all 84 lock sites, every later PDO + /// call in that process — on unrelated connections, in unrelated request handlers + /// — would then panic on the poisoned lock, and each of those panics would abort + /// across the C ABI: one transient failure bricked the whole bridge. The tables + /// are plain maps and stay structurally valid, so `lock_recover` reclaims the + /// guard instead of re-panicking. This test poisons `conns()` for real (a thread + /// that panics while holding it) and then proves the bridge still serves: the + /// unknown-handle sentinels still come back, and a brand-new SQLite connection + /// still opens, executes, steps and closes. + /// + /// The poison is process-global and deliberately NOT undone — that is the point: + /// every other test in this binary keeps passing afterwards precisely because + /// nothing reaches a poisoned lock through `.unwrap()` any more. + #[test] + fn poisoned_handle_table_does_not_brick_the_bridge() { + let poisoner = std::thread::spawn(|| { + // `lock_recover` (not `.lock().unwrap()`) so the test is robust whether or + // not the table is already poisoned; the guard's drop during the unwind is + // what sets the poison flag either way. + let _guard = lock_recover(conns()); + panic!("deliberate poison of the conns() table (F-QUAL-02 test) — expected"); + }); + assert!( + poisoner.join().is_err(), + "the poisoning thread was supposed to panic" + ); + assert!( + conns().is_poisoned(), + "precondition: the conns() mutex must actually be poisoned", + ); + + // Every entry point that reads the poisoned table still answers, and answers + // with its documented sentinel rather than panicking (which would abort). + let probe = cs("SELECT 1"); + assert_eq!(unsafe { elephc_pdo_exec(999_999, probe.as_ptr()) }, -1); + assert_eq!(unsafe { elephc_pdo_prepare(999_999, probe.as_ptr(), 0) }, -1); + assert_eq!(elephc_pdo_begin(999_999), 0); + assert_eq!(unsafe { read(elephc_pdo_sqlstate(999_999)) }, "00000"); + + // …and the bridge is still fully usable on a fresh connection. + let dsn = cs("sqlite::memory:"); + let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; + assert!(conn > 0, "open must still work through a poisoned conns()"); + let ddl = cs("CREATE TABLE t (n INTEGER)"); + assert_eq!(unsafe { elephc_pdo_exec(conn, ddl.as_ptr()) }, 0); + let ins = cs("INSERT INTO t VALUES (42)"); + assert_eq!(unsafe { elephc_pdo_exec(conn, ins.as_ptr()) }, 1); + let sel = cs("SELECT n FROM t"); + let stmt = unsafe { elephc_pdo_prepare(conn, sel.as_ptr(), 0) }; + assert!(stmt > 0, "prepare must still work through a poisoned conns()"); + assert_eq!(elephc_pdo_step(stmt), 1); + assert_eq!(elephc_pdo_column_int(stmt, 0), 42); + assert_eq!(elephc_pdo_finalize(stmt), 1); + elephc_pdo_close(conn); + } + + /// Full in-memory SQLite round-trip: open, create, insert, prepared select + /// with a positional bind, step, and read typed columns back. + #[test] + fn sqlite_in_memory_round_trip() { + let dsn = cs("sqlite::memory:"); + let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; + assert!(conn > 0, "open failed"); + + let ddl = cs("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + assert_eq!(unsafe { elephc_pdo_exec(conn, ddl.as_ptr()) }, 0); + + let ins = cs("INSERT INTO users (name, score) VALUES ('Alice', 9.5)"); + assert_eq!(unsafe { elephc_pdo_exec(conn, ins.as_ptr()) }, 1); + assert_eq!( + unsafe { elephc_pdo_last_insert_id(conn, std::ptr::null()) }, + 1 + ); + + let sql = cs("SELECT id, name, score FROM users WHERE id = ?"); + let stmt = unsafe { elephc_pdo_prepare(conn, sql.as_ptr(), 0) }; + assert!(stmt > 0, "prepare failed"); + assert_eq!(elephc_pdo_bind_int(stmt, 1, 1), 1); + + assert_eq!(elephc_pdo_step(stmt), 1); + assert_eq!(elephc_pdo_column_count(stmt), 3); + assert_eq!(elephc_pdo_column_int(stmt, 0), 1); + assert_eq!(unsafe { read(elephc_pdo_column_name(stmt, 1)) }, "name"); + // v24/F-QUAL-03: the NUL-stripping `elephc_pdo_column_text` is gone; a text + // column is read through the same byte-exact len+ptr pair the prelude uses. + let name_len = elephc_pdo_column_data_len(stmt, 1); + let name_ptr = elephc_pdo_column_data_ptr(stmt, 1); + assert_eq!(unsafe { read_bytes(name_ptr, name_len) }, b"Alice"); + assert_eq!(elephc_pdo_column_double(stmt, 2), 9.5); + assert_eq!(elephc_pdo_step(stmt), 0); + + assert_eq!(elephc_pdo_finalize(stmt), 1); + elephc_pdo_close(conn); + } + + /// SQLite BLOB data returned through the raw data API preserves embedded NUL + /// bytes instead of truncating through the legacy C-string bridge. + #[test] + fn sqlite_blob_round_trip_preserves_embedded_nul() { + let dsn = cs("sqlite::memory:"); + let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; + assert!(conn > 0, "open failed"); + + let ddl = cs("CREATE TABLE blobs (data BLOB)"); + assert_eq!(unsafe { elephc_pdo_exec(conn, ddl.as_ptr()) }, 0); + + let ins = cs("INSERT INTO blobs (data) VALUES (x'410042')"); + assert_eq!(unsafe { elephc_pdo_exec(conn, ins.as_ptr()) }, 1); + + let sql = cs("SELECT data FROM blobs"); + let stmt = unsafe { elephc_pdo_prepare(conn, sql.as_ptr(), 0) }; + assert!(stmt > 0, "prepare failed"); + assert_eq!(elephc_pdo_step(stmt), 1); + assert_eq!(elephc_pdo_column_type(stmt, 0), 4); + assert_eq!(elephc_pdo_column_data_len(stmt, 0), 3); + let ptr = elephc_pdo_column_data_ptr(stmt, 0); + assert_eq!(unsafe { read_bytes(ptr, 3) }, b"A\0B"); + assert_eq!(elephc_pdo_column_data_byte(stmt, 0, 0), 65); + assert_eq!(elephc_pdo_column_data_byte(stmt, 0, 1), 0); + assert_eq!(elephc_pdo_column_data_byte(stmt, 0, 2), 66); + assert_eq!(elephc_pdo_column_data_byte(stmt, 0, 3), 0); + + assert_eq!(elephc_pdo_finalize(stmt), 1); + elephc_pdo_close(conn); + } + + /// F-QUAL-01 (ABI v24), bridge side: `elephc_pdo_blob_data_ptr` is the BULK + /// copy-out path `blobStream()` now uses — one FFI call for the whole value, + /// instead of one `elephc_pdo_blob_byte` call per byte (each locking and + /// unlocking the handle table). Two properties are load-bearing and pinned here. + /// (1) It is byte-exact: the buffer is length-counted and never routed through + /// the NUL-stripping `store_cstr`, so `x'610062'` ("a\0b") survives whole — the + /// only reason the byte loop existed. (2) It returns a NULL pointer when the + /// buffer is EMPTY (a zero-length BLOB, `x''`), which is exactly why the prelude + /// guards its `ptr_read_string` call with `if ($_len > 0)`: `ptr_read_string` + /// runs `__rt_ptr_check_nonnull` BEFORE it ever looks at the length, so an empty + /// BLOB reaching it would hard-abort the process rather than yield `""`. + #[test] + fn sqlite_blob_data_ptr_is_byte_exact_and_null_when_empty() { + let dsn = cs("sqlite::memory:"); + let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; + assert!(conn > 0, "open failed"); + + let ddl = cs("CREATE TABLE imgs (id INTEGER PRIMARY KEY, body BLOB)"); + assert_eq!(unsafe { elephc_pdo_exec(conn, ddl.as_ptr()) }, 0); + let ins = cs("INSERT INTO imgs (id, body) VALUES (1, x'610062'), (2, x'')"); + assert_eq!(unsafe { elephc_pdo_exec(conn, ins.as_ptr()) }, 2); + + let table = cs("imgs"); + let column = cs("body"); + let len = unsafe { + elephc_pdo_blob_read(conn, table.as_ptr(), column.as_ptr(), 1, std::ptr::null()) + }; + assert_eq!(len, 3); + let ptr = elephc_pdo_blob_data_ptr(); + assert!(!ptr.is_null(), "a non-empty BLOB must expose its buffer"); + assert_eq!(unsafe { read_bytes(ptr, len) }, b"a\0b"); + + // v40 writeback is length-counted as well: an embedded NUL must not + // truncate the replacement snapshot at the C ABI boundary. + let replacement = b"Z\0Q"; + assert_eq!( + unsafe { + elephc_pdo_blob_write( + conn, + table.as_ptr(), + column.as_ptr(), + 1, + std::ptr::null(), + replacement.as_ptr() as *const c_char, + replacement.len() as i64, + ) + }, + 1, + ); + let replaced = unsafe { + elephc_pdo_blob_read(conn, table.as_ptr(), column.as_ptr(), 1, std::ptr::null()) + }; + assert_eq!(replaced, 3); + assert_eq!( + unsafe { read_bytes(elephc_pdo_blob_data_ptr(), replaced) }, + replacement, + ); + + // v46 performs the same operation in bounded slices: size is scalar-only, + // reads copy only the requested range, and writes patch only that range. + assert_eq!( + unsafe { + elephc_pdo_blob_size( + conn, + table.as_ptr(), + column.as_ptr(), + 1, + std::ptr::null(), + ) + }, + 3, + ); + let slice_len = unsafe { + elephc_pdo_blob_read_at( + conn, + table.as_ptr(), + column.as_ptr(), + 1, + std::ptr::null(), + 1, + 1, + ) + }; + assert_eq!(slice_len, 1); + assert_eq!(unsafe { read_bytes(elephc_pdo_blob_data_ptr(), slice_len) }, b"\0"); + let patch = b"X"; + assert_eq!( + unsafe { + elephc_pdo_blob_write_at( + conn, + table.as_ptr(), + column.as_ptr(), + 1, + std::ptr::null(), + 1, + patch.as_ptr() as *const c_char, + patch.len() as i64, + ) + }, + 1, + ); + let patched = unsafe { + elephc_pdo_blob_read(conn, table.as_ptr(), column.as_ptr(), 1, std::ptr::null()) + }; + assert_eq!(unsafe { read_bytes(elephc_pdo_blob_data_ptr(), patched) }, b"ZXQ"); + assert_eq!( + unsafe { + elephc_pdo_blob_write_at( + conn, + table.as_ptr(), + column.as_ptr(), + 1, + std::ptr::null(), + 3, + patch.as_ptr() as *const c_char, + 1, + ) + }, + -1, + "bounded writes must not extend SQLite's fixed-size BLOB", + ); + + // The zero-length BLOB: a successful read of 0 bytes, and a NULL data pointer. + let empty = unsafe { + elephc_pdo_blob_read(conn, table.as_ptr(), column.as_ptr(), 2, std::ptr::null()) + }; + assert_eq!(empty, 0, "a zero-length BLOB reads successfully, as 0 bytes"); + assert!( + elephc_pdo_blob_data_ptr().is_null(), + "an empty buffer must report NULL, which is what the prelude's `$_len > 0` guard is for", + ); + + // A missing row is still the -1 failure sentinel, distinct from "0 bytes". + let missing = unsafe { + elephc_pdo_blob_read(conn, table.as_ptr(), column.as_ptr(), 999, std::ptr::null()) + }; + assert_eq!(missing, -1); + + elephc_pdo_close(conn); + } + + /// F-SQLT-02 (ABI v24), bridge side: `Pdo\Sqlite::ATTR_EXTENDED_RESULT_CODES` was + /// stored and otherwise a total no-op. php-src's `pdo_sqlite_set_attribute` calls + /// `sqlite3_extended_result_codes(H->db, lval)`, which widens the value + /// `sqlite3_errcode()` reports — and which PDO surfaces as `errorInfo[1]` — from + /// the coarse primary code (`SQLITE_CONSTRAINT` = 19, "a constraint broke") to the + /// extended one naming the constraint (`SQLITE_CONSTRAINT_UNIQUE` = 2067). The + /// attribute is a live toggle, so turning it back off must restore the primary + /// code; a non-SQLite/unknown handle answers `0` (no-op), never panics. + #[test] + fn sqlite_extended_result_codes_widen_the_native_errcode() { + let dsn = cs("sqlite::memory:"); + let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; + assert!(conn > 0, "open failed"); + + let ddl = cs("CREATE TABLE t (id INTEGER PRIMARY KEY, u TEXT UNIQUE)"); + assert_eq!(unsafe { elephc_pdo_exec(conn, ddl.as_ptr()) }, 0); + let seed = cs("INSERT INTO t (id, u) VALUES (1, 'a')"); + assert_eq!(unsafe { elephc_pdo_exec(conn, seed.as_ptr()) }, 1); + + // Off (the SQLite default): the primary code, SQLITE_CONSTRAINT. + let dup2 = cs("INSERT INTO t (id, u) VALUES (2, 'a')"); + assert_eq!(unsafe { elephc_pdo_exec(conn, dup2.as_ptr()) }, -1); + assert_eq!(elephc_pdo_errcode(conn), 19); + + // On: the extended code, SQLITE_CONSTRAINT_UNIQUE (19 | 8 << 8). + assert_eq!(elephc_pdo_set_extended_result_codes(conn, 1), 1); + let dup3 = cs("INSERT INTO t (id, u) VALUES (3, 'a')"); + assert_eq!(unsafe { elephc_pdo_exec(conn, dup3.as_ptr()) }, -1); + assert_eq!(elephc_pdo_errcode(conn), 2067); + + // Off again: back to the primary code (it is a toggle, not a latch). + assert_eq!(elephc_pdo_set_extended_result_codes(conn, 0), 1); + let dup4 = cs("INSERT INTO t (id, u) VALUES (4, 'a')"); + assert_eq!(unsafe { elephc_pdo_exec(conn, dup4.as_ptr()) }, -1); + assert_eq!(elephc_pdo_errcode(conn), 19); + + elephc_pdo_close(conn); + + // A handle that is not a live SQLite connection is a no-op, not a panic. + assert_eq!(elephc_pdo_set_extended_result_codes(999_999, 1), 0); + } + + /// F-QUAL-04: `SqliteConn`/`SqliteStmt` now carry `impl Drop` (sqlite3_close / + /// sqlite3_finalize) as a defense-in-depth net, since their safety used to rest + /// entirely on the two explicit `close()`/`finalize()` call sites never being + /// missed. The net's own risk is the mirror image — a DOUBLE free: `Drop` runs + /// when the handle leaves the table, right after the explicit release already ran + /// on the very same raw pointer, and `sqlite3_finalize`/`sqlite3_close` on an + /// already-destroyed handle is undefined behavior (in practice an abort or heap + /// corruption, not a clean error). The releases are therefore guarded by a + /// `released` flag, which this test drives from the outside: every normal path + /// releases a handle exactly ONCE, a second explicit release is a reported no-op + /// (`0`, "unknown handle" — it was already removed from the table), and the + /// process survives to answer the next call. + #[test] + fn sqlite_close_and_finalize_are_idempotent_under_the_drop_net() { + let dsn = cs("sqlite::memory:"); + let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; + assert!(conn > 0, "open failed"); + + let sql = cs("SELECT 1"); + let stmt = unsafe { elephc_pdo_prepare(conn, sql.as_ptr(), 0) }; + assert!(stmt > 0, "prepare failed"); + assert_eq!(elephc_pdo_step(stmt), 1); + + // Explicit release, then Drop on the way out of the table: exactly one free. + assert_eq!(elephc_pdo_finalize(stmt), 1); + assert_eq!( + elephc_pdo_finalize(stmt), + 0, + "a second finalize must be an unknown-handle no-op, not a second free", + ); + + elephc_pdo_close(conn); + // A second close must not re-enter sqlite3_close on the freed handle. + elephc_pdo_close(conn); + assert_eq!( + unsafe { elephc_pdo_exec(conn, sql.as_ptr()) }, + -1, + "the closed connection's handle must no longer resolve", + ); + } + + /// SQLite persistent opens reuse a process-local connection keyed by the + /// `(DSN, persistent-key)` pair — here the empty key both opens pass, i.e. the + /// plain boolean-persistent pool (F-CORE-16) — and a close call leaves that + /// pooled connection available to the next open. + #[test] + fn sqlite_persistent_pool_reuses_connection_after_close() { + let dsn = cs("sqlite::memory:"); + let first = unsafe { + elephc_pdo_open_persistent( + dsn.as_ptr(), + 1, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null(), + ) + }; + assert!(first > 0, "open failed"); + + let ddl = cs("CREATE TABLE persistent_pool (n INTEGER)"); + assert_eq!(unsafe { elephc_pdo_exec(first, ddl.as_ptr()) }, 0); + let ins = cs("INSERT INTO persistent_pool VALUES (77)"); + assert_eq!(unsafe { elephc_pdo_exec(first, ins.as_ptr()) }, 1); + elephc_pdo_close(first); + + let second = unsafe { + elephc_pdo_open_persistent( + dsn.as_ptr(), + 1, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null(), + ) + }; + assert_eq!(second, first); + let sql = cs("SELECT n FROM persistent_pool"); + let stmt = unsafe { elephc_pdo_prepare(second, sql.as_ptr(), 0) }; + assert!(stmt > 0, "prepare failed"); + assert_eq!(elephc_pdo_step(stmt), 1); + assert_eq!(elephc_pdo_column_int(stmt, 0), 77); + assert_eq!(elephc_pdo_finalize(stmt), 1); + } + + /// ABI v44 tracks simultaneous PDO owners of one persistent handle and only + /// marks the pooled session idle after the final release. + #[test] + fn persistent_release_counts_live_owners() { + let dsn = cs("sqlite::memory:"); + let key = cs("v44-owner-count"); + let first = unsafe { + elephc_pdo_open_persistent( + dsn.as_ptr(), + 1, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + key.as_ptr(), + std::ptr::null(), + ) + }; + let second = unsafe { + elephc_pdo_open_persistent( + dsn.as_ptr(), + 1, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + key.as_ptr(), + std::ptr::null(), + ) + }; + assert_eq!(first, second); + assert_eq!(lock_recover(persistent_owner_counts()).get(&first), Some(&2)); + + elephc_pdo_release(first, 1); + assert_eq!(lock_recover(persistent_owner_counts()).get(&first), Some(&1)); + elephc_pdo_release(second, 1); + assert_eq!(lock_recover(persistent_owner_counts()).get(&first), Some(&0)); + + let reused = unsafe { + elephc_pdo_open_persistent( + dsn.as_ptr(), + 1, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + key.as_ptr(), + std::ptr::null(), + ) + }; + assert_eq!(reused, first); + assert_eq!(lock_recover(persistent_owner_counts()).get(&first), Some(&1)); + elephc_pdo_release(reused, 1); + } + + /// F-CORE-16: the persistent pool key is the `(DSN, persistent-key)` PAIR, not the + /// DSN alone. php-src builds its persistent hashkey from both whenever + /// `PDO::ATTR_PERSISTENT` was given as a non-numeric, non-empty string + /// (`pdo_dbh.c:389-404`) — separating two named pools onto distinct connections is + /// the entire point of that spelling — and elephc used to cast the option to `(bool)` + /// and pool by DSN alone, so two differently-named persistent pools silently SHARED + /// one connection. + /// + /// Driven through the C ABI exactly as the pooled-reuse test above drives it, with + /// `persistent_key` (the v25 trailing parameter) as the only difference between the + /// opens. Distinct handle IDs alone would be a weak assertion — the pool could hand + /// back two IDs aliasing one `sqlite3*` — so distinctness is PROVEN at the database + /// level: `sqlite::memory:` gives each real connection its own private in-memory + /// database, so the same `CREATE TABLE` must SUCCEED on both (`0` rows affected). + /// Were they one shared connection, the second `CREATE TABLE` would fail with "table + /// already exists" and `elephc_pdo_exec` would return `-1`. + /// + /// The key strings are unique to this test, so it cannot collide with the + /// process-global pool entries any other test in this binary registers (they all use + /// the empty key). + #[test] + fn sqlite_persistent_pool_key_includes_the_attr_persistent_string() { + let dsn = cs("sqlite::memory:"); + let key_alpha = cs("fcore16-alpha"); + let key_beta = cs("fcore16-beta"); + + let alpha = unsafe { + elephc_pdo_open_persistent( + dsn.as_ptr(), + 1, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + key_alpha.as_ptr(), + std::ptr::null(), + ) + }; + assert!(alpha > 0, "open under key alpha failed"); + + let beta = unsafe { + elephc_pdo_open_persistent( + dsn.as_ptr(), + 1, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + key_beta.as_ptr(), + std::ptr::null(), + ) + }; + assert!(beta > 0, "open under key beta failed"); + assert_ne!( + alpha, beta, + "same DSN under DIFFERENT ATTR_PERSISTENT keys must be DISTINCT pooled \ + connections (php-src pdo_dbh.c:389-404)", + ); + + // Same DDL on both: it can only succeed twice if these are two real, separate + // `sqlite::memory:` databases rather than one connection behind two handle IDs. + let ddl = cs("CREATE TABLE keyed_pool (n INTEGER)"); + assert_eq!( + unsafe { elephc_pdo_exec(alpha, ddl.as_ptr()) }, + 0, + "DDL on the alpha-keyed connection failed", + ); + assert_eq!( + unsafe { elephc_pdo_exec(beta, ddl.as_ptr()) }, + 0, + "the beta-keyed connection shares alpha's database — the pool key ignored the \ + ATTR_PERSISTENT string", + ); + + // And the SAME key still pools: a second open under alpha's key reuses alpha's + // handle rather than dialing a fresh connection (the reuse half of the pair key). + let alpha_again = unsafe { + elephc_pdo_open_persistent( + dsn.as_ptr(), + 1, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + key_alpha.as_ptr(), + std::ptr::null(), + ) + }; + assert_eq!( + alpha_again, alpha, + "the SAME (DSN, persistent-key) pair must reuse the pooled connection", + ); + } + + /// F-MY-02: `unix_socket` is only honored when the DSN names NO host, or names + /// exactly `localhost`. php-src's MySQL handle factory takes the socket under + /// precisely that condition — `if (vars[0].optval && !strcmp("localhost", + /// vars[0].optval))` (`mysql_driver.c:940-946`), with the DSN parser defaulting an + /// absent `host` to `"localhost"` — so `mysql:host=127.0.0.1;unix_socket=…` is + /// TCP-only in real PHP and the socket key is silently ignored. Preferring the socket + /// whenever it appeared (as `build_opts` did) connected such a DSN to a DIFFERENT + /// SERVER than php-src would: same DSN, different database. + /// + /// `127.0.0.1` is deliberately NOT `localhost` here — the comparison is php-src's + /// case-sensitive `strcmp`, and the MySQL client itself draws the same distinction. + /// Pure DSN-parsing logic: `build_opts` never dials out, so no server is needed. + /// (It lives in this `mod tests` rather than `my.rs`'s only because of how this + /// change was split across owners; its subject is `my::build_opts`.) + #[test] + fn build_opts_ignores_unix_socket_when_the_host_is_a_real_address() { + let (opts, _charset) = crate::my::build_opts( + "mysql:host=127.0.0.1;port=3307;unix_socket=/tmp/mysql.sock;dbname=testdb", + false, + false, + ) + .expect("build_opts rejected a valid mysql: DSN"); + let opts: mysql::Opts = opts.into(); + assert_eq!( + opts.get_socket(), + None, + "a non-localhost host must force the TCP path and drop the socket key", + ); + assert_eq!(opts.get_ip_or_hostname(), "127.0.0.1"); + assert_eq!(opts.get_tcp_port(), 3307); + } + + /// F-MY-03: the NO_BACKSLASH_ESCAPES-aware placeholder scanner. Under that `sql_mode` + /// the SERVER treats `\` as an ORDINARY BYTE inside a string literal — doubling is then + /// the only escape — so the scanner has to agree with it about where a literal ENDS. + /// + /// `SELECT 'it\', ? FROM t` is the shape that makes the disagreement observable, and it + /// flips the PLACEHOLDER COUNT, not some cosmetic detail: + /// * NBE **true** — the `\` is a literal byte, so the `'` right after it CLOSES the + /// string. The literal is `'it\'` and the `?` that follows is a REAL placeholder: + /// 1 slot. + /// * NBE **false** — the `\` escapes the `'`, so the literal does NOT end there; the + /// scanner continues to the quote before `tail` and SWALLOWS the `?` as string + /// content: 0 slots. + /// + /// Assuming backslash-escaping under NBE therefore yields a bound-parameter count that + /// disagrees with the server's real one — precisely what this flag exists to prevent. + /// Pure scanning logic: `translate_placeholders` never dials out, so no server is needed. + /// (Like the `build_opts` tests around it, its subject is `my`; it lives in this + /// `mod tests` only because of how this change was split across owners.) + #[test] + fn translate_placeholders_honors_no_backslash_escapes() { + // Rust `\\` is ONE literal backslash. The quote after it closes under NBE; + // under the default mode it is escaped and the quote before tail closes instead. + let sql = "SELECT 'it\\', ?, 'tail' FROM t"; + + let (_sql, named, order, mixed) = crate::my::translate_placeholders(sql, true); + assert_eq!( + order.len(), + 1, + "under NO_BACKSLASH_ESCAPES the backslash is a literal byte, so the string \ + closes at the following quote and the trailing ? is a real placeholder", + ); + assert!(named.is_empty(), "there are no :name placeholders in this SQL"); + assert!(!mixed, "a lone positional ? must not read as mixed named/positional"); + + let (_sql, _named, order, _mixed) = crate::my::translate_placeholders(sql, false); + assert_eq!( + order.len(), + 0, + "with backslash-escaping the \\' does NOT close the literal, so the ? is \ + swallowed as string content", + ); + } + + /// F-MY-03, the negative control: the flag must change ONLY the backslash rule. A + /// placeholder outside any literal, and the doubled-quote escape (`''`, an escape in + /// BOTH modes), behave identically whichever way the flag is set. Without this, the + /// test above would also pass for a scanner that simply mis-scans under one mode. + #[test] + fn translate_placeholders_is_otherwise_unchanged_by_no_backslash_escapes() { + // `'it''s'` is a doubled-quote escape in both modes: the literal ends at its final + // quote, and BOTH `?` placeholders are real. + let sql = "SELECT ? FROM t WHERE a = 'it''s' AND b = ?"; + for nbe in [false, true] { + let (_sql, named, order, mixed) = crate::my::translate_placeholders(sql, nbe); + assert_eq!( + order.len(), + 2, + "doubled-quote escaping is mode-independent (no_backslash_escapes={nbe})", + ); + assert!(named.is_empty()); + assert!(!mixed); + } + } + + /// F-MY-02, the two cases where the socket DOES win: `host=localhost` (php-src's + /// literal `strcmp` match) and a DSN naming no host at all (php-src's parser defaults + /// `host` to `"localhost"`, so it takes the same arm). Without this negative control, + /// a `build_opts` that simply never honored `unix_socket` would pass the test above. + #[test] + fn build_opts_honors_unix_socket_for_localhost_and_for_a_hostless_dsn() { + let (opts, _charset) = crate::my::build_opts( + "mysql:host=localhost;unix_socket=/tmp/mysql.sock;dbname=testdb", + false, + false, + ) + .expect("build_opts rejected a valid mysql: DSN"); + let opts: mysql::Opts = opts.into(); + assert_eq!( + opts.get_socket(), + Some("/tmp/mysql.sock"), + "host=localhost must take the unix_socket path", + ); + + let (opts, _charset) = + crate::my::build_opts( + "mysql:unix_socket=/tmp/mysql.sock;dbname=testdb", + false, + false, + ) + .expect("build_opts rejected a valid mysql: DSN"); + let opts: mysql::Opts = opts.into(); + assert_eq!( + opts.get_socket(), + Some("/tmp/mysql.sock"), + "a host-less DSN defaults to localhost, so it too must take the socket", + ); + } + + /// F-MY-06: `Pdo\Mysql::ATTR_FOUND_ROWS` ORs `CLIENT_FOUND_ROWS` into the connect + /// handshake's capability flags (php-src `mysql_driver.c:776-778`), which switches + /// what the server reports as an UPDATE's affected-row count — and so + /// `PDOStatement::rowCount()` — from "rows actually CHANGED" to "rows MATCHED by the + /// WHERE clause". It is a HANDSHAKE capability, so it can only be selected at connect + /// time; it was unwired entirely, leaving no way to opt into the matched-rows + /// semantics apps commonly rely on (the difference between `1` and `0` for an UPDATE + /// writing a value a row already holds). + /// + /// Asserted on the BUILT `Opts` — the capability is a bit in the options, so no + /// server is needed to prove it is set iff requested. The `mysql` crate ORs + /// `additional_capabilities` into the handshake's client flags, and its + /// forbidden-flag filter covers only the capabilities the connection manages itself + /// (`CLIENT_SSL`, `CLIENT_COMPRESS`, the MULTI_* pair, …) — never `CLIENT_FOUND_ROWS` + /// — so a bit present here does reach the server. #[test] - fn open_rejects_unknown_driver_dsn() { - let dsn = cs("oracle:host=localhost"); - let id = unsafe { elephc_pdo_open(dsn.as_ptr()) }; - assert_eq!(id, -1); - let msg = unsafe { read(elephc_pdo_last_open_error()) }; - assert!(msg.contains("driver"), "got: {msg}"); + fn build_opts_sets_client_found_rows_only_when_requested() { + let (opts, _charset) = + crate::my::build_opts("mysql:host=localhost;dbname=testdb", true, false) + .expect("build_opts rejected a valid mysql: DSN"); + let opts: mysql::Opts = opts.into(); + assert!( + opts.get_additional_capabilities() + .contains(mysql::consts::CapabilityFlags::CLIENT_FOUND_ROWS), + "ATTR_FOUND_ROWS must OR CLIENT_FOUND_ROWS into the handshake capabilities", + ); + + let (opts, _charset) = + crate::my::build_opts("mysql:host=localhost;dbname=testdb", false, false) + .expect("build_opts rejected a valid mysql: DSN"); + let opts: mysql::Opts = opts.into(); + assert!( + !opts + .get_additional_capabilities() + .contains(mysql::consts::CapabilityFlags::CLIENT_FOUND_ROWS), + "without ATTR_FOUND_ROWS the capability must stay off (php-src's default: an \ + UPDATE's rowCount() reports rows CHANGED)", + ); } - /// Unknown handles return the documented sentinels rather than panicking. + /// F-CORE-02: pins the LAST-KEY-WINS property of `build_opts`'s DSN pair loop, which + /// is the mechanism the prelude's MySQL credential precedence is built on and which + /// nothing else in CI covers. + /// + /// php-src is asymmetric by driver: for `pgsql:` the DSN wins, but for `mysql:` the + /// CONSTRUCTOR ARGUMENTS win and the DSN's own `user=`/`password=` are only a fallback + /// (`mysql_driver.c:948-953`). The prelude implements that by APPENDING `;user=…` / + /// `;password=…` to the DSN for a `mysql:` connection (`src/pdo_prelude.rs:691-698`) — + /// which overrides the DSN's keys only because the loop above reassigns `user`/ + /// `password` on every occurrence, so the trailing pair is the one that survives. + /// + /// That coupling is invisible from either side alone: rewriting the loop to take the + /// FIRST occurrence (`user.get_or_insert(value)`) would still pass every other test + /// here while silently restoring the pre-F-CORE-02 bug — `new PDO("mysql:…;user= + /// readonly", "admin", $pw)` connecting as `readonly`. The only other coverage is a + /// live `#[ignore]` test that never runs in CI, so this asserts it on the built `Opts`, + /// where no server is needed to prove which credentials would be sent. #[test] - fn unknown_handles_return_sentinels() { - assert_eq!(elephc_pdo_step(999_999), -1); - assert_eq!(elephc_pdo_column_count(999_999), 0); - assert_eq!(elephc_pdo_finalize(999_999), 0); + fn build_opts_lets_the_last_user_and_password_keys_win() { + let (opts, _charset) = crate::my::build_opts( + "mysql:host=localhost;dbname=testdb;user=readonly;password=weak;user=admin;\ + password=strong", + false, + false, + ) + .expect("build_opts rejected a valid mysql: DSN"); + let opts: mysql::Opts = opts.into(); + assert_eq!( + opts.get_user(), + Some("admin"), + "the trailing user= key (the prelude's appended constructor argument) must win \ + over the one already in the DSN", + ); + assert_eq!( + opts.get_pass(), + Some("strong"), + "the trailing password= key (the prelude's appended constructor argument) must \ + win over the one already in the DSN", + ); } - /// Full in-memory SQLite round-trip: open, create, insert, prepared select - /// with a positional bind, step, and read typed columns back. + /// v7 SQLite coverage: a clean DDL/INSERT reports SQLSTATE `"00000"`; + /// `elephc_pdo_bind_bool` and `elephc_pdo_bind_blob` round-trip through + /// `column_int`/`column_data_ptr` (the blob preserving an embedded NUL byte); + /// `elephc_pdo_set_busy_timeout` reports success; `elephc_pdo_server_version` + /// returns the bundled SQLite version string; and a duplicate PRIMARY KEY + /// insert reports SQLSTATE `"23000"` (SQLite's `SQLITE_CONSTRAINT`). #[test] - fn sqlite_in_memory_round_trip() { + fn sqlite_v7_sqlstate_and_new_binds() { let dsn = cs("sqlite::memory:"); let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; assert!(conn > 0, "open failed"); - let ddl = cs("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + assert_eq!(elephc_pdo_set_busy_timeout(conn, 5000), 1); + + let version = unsafe { read(elephc_pdo_server_version(conn)) }; + assert!(!version.is_empty(), "server_version was empty"); + assert!( + version.chars().next().is_some_and(|c| c.is_ascii_digit()), + "got: {version}" + ); + + let ddl = cs("CREATE TABLE t (id INTEGER PRIMARY KEY, flag INTEGER, data BLOB)"); assert_eq!(unsafe { elephc_pdo_exec(conn, ddl.as_ptr()) }, 0); + assert_eq!(unsafe { read(elephc_pdo_sqlstate(conn)) }, "00000"); - let ins = cs("INSERT INTO users (name, score) VALUES ('Alice', 9.5)"); - assert_eq!(unsafe { elephc_pdo_exec(conn, ins.as_ptr()) }, 1); + let ins = cs("INSERT INTO t (id, flag, data) VALUES (?, ?, ?)"); + let stmt = unsafe { elephc_pdo_prepare(conn, ins.as_ptr(), 0) }; + assert!(stmt > 0, "prepare failed"); + assert_eq!(elephc_pdo_bind_int(stmt, 1, 1), 1); + assert_eq!(elephc_pdo_bind_bool(stmt, 2, 1), 1); + let blob = b"A\0B"; assert_eq!( - unsafe { elephc_pdo_last_insert_id(conn, std::ptr::null()) }, + unsafe { + elephc_pdo_bind_blob(stmt, 3, blob.as_ptr() as *const c_char, blob.len() as i64) + }, 1 ); + assert_eq!(elephc_pdo_step(stmt), 0); + assert_eq!(elephc_pdo_finalize(stmt), 1); + assert_eq!(unsafe { read(elephc_pdo_sqlstate(conn)) }, "00000"); + assert_eq!( + unsafe { read(elephc_pdo_last_insert_id_text(conn, std::ptr::null())) }, + "1" + ); - let sql = cs("SELECT id, name, score FROM users WHERE id = ?"); - let stmt = unsafe { elephc_pdo_prepare(conn, sql.as_ptr()) }; - assert!(stmt > 0, "prepare failed"); - assert_eq!(elephc_pdo_bind_int(stmt, 1, 1), 1); + // The bound bool (as 0/1) and blob (with its embedded NUL) round-trip. + let sel = cs("SELECT flag, data FROM t WHERE id = 1"); + let q = unsafe { elephc_pdo_prepare(conn, sel.as_ptr(), 0) }; + assert!(q > 0, "prepare failed"); + assert_eq!(elephc_pdo_step(q), 1); + assert_eq!(elephc_pdo_column_int(q, 0), 1); + assert_eq!(elephc_pdo_column_data_len(q, 1), 3); + let ptr = elephc_pdo_column_data_ptr(q, 1); + assert_eq!(unsafe { read_bytes(ptr, 3) }, b"A\0B"); + assert_eq!(elephc_pdo_finalize(q), 1); - assert_eq!(elephc_pdo_step(stmt), 1); - assert_eq!(elephc_pdo_column_count(stmt), 3); - assert_eq!(elephc_pdo_column_int(stmt, 0), 1); - assert_eq!(unsafe { read(elephc_pdo_column_name(stmt, 1)) }, "name"); - assert_eq!(unsafe { read(elephc_pdo_column_text(stmt, 1)) }, "Alice"); - assert_eq!(elephc_pdo_column_double(stmt, 2), 9.5); - assert_eq!(elephc_pdo_step(stmt), 0); + // A duplicate PRIMARY KEY hits SQLite's SQLITE_CONSTRAINT, SQLSTATE + // "23000", visible through both the connection- and statement-level + // accessors right after the failing step. (SQLite only guarantees + // `sqlite3_errcode()` reflects the *most recently failed* call, so this + // reads it immediately rather than after any later successful call.) + let dup = cs("INSERT INTO t (id, flag, data) VALUES (?, 0, NULL)"); + let dup_stmt = unsafe { elephc_pdo_prepare(conn, dup.as_ptr(), 0) }; + assert!(dup_stmt > 0, "prepare failed"); + assert_eq!(elephc_pdo_bind_int(dup_stmt, 1, 1), 1); + assert_eq!(elephc_pdo_step(dup_stmt), -1); + assert_eq!(unsafe { read(elephc_pdo_sqlstate(conn)) }, "23000"); + assert_eq!(unsafe { read(elephc_pdo_stmt_sqlstate(dup_stmt)) }, "23000"); + assert_eq!(elephc_pdo_stmt_errcode(dup_stmt), elephc_pdo_errcode(conn)); + assert_eq!( + unsafe { read(elephc_pdo_stmt_errmsg(dup_stmt)) }, + unsafe { read(elephc_pdo_errmsg(conn)) } + ); + assert_eq!(elephc_pdo_finalize(dup_stmt), 1); - assert_eq!(elephc_pdo_finalize(stmt), 1); elephc_pdo_close(conn); } - /// SQLite BLOB data returned through the raw data API preserves embedded NUL - /// bytes instead of truncating through the legacy C-string bridge. + /// v17 (P1-10): `Pdo\Sqlite::ATTR_OPEN_FLAGS` threaded through + /// `elephc_pdo_open_persistent`'s `sqlite_open_flags` opens a connection that + /// rejects writes when the flags select `SQLITE_OPEN_READONLY` (`1`, matching + /// `Pdo\Sqlite::OPEN_READONLY`) instead of the default `READWRITE|CREATE`. #[test] - fn sqlite_blob_round_trip_preserves_embedded_nul() { + fn sqlite_open_flags_readonly_rejects_write() { + let path = std::env::temp_dir().join(format!( + "elephc_pdo_test_readonly_{}_{}.sqlite", + std::process::id(), + next_id() + )); + let _ = std::fs::remove_file(&path); + let dsn = cs(&format!("sqlite:{}", path.display())); + + // A non-persistent (flag 0) open with sqlite_open_flags=0 creates the file + // read-write, as today's default does. + let rw = unsafe { + elephc_pdo_open_persistent( + dsn.as_ptr(), + 0, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null(), + ) + }; + assert!(rw > 0, "read-write open failed"); + let ddl = cs("CREATE TABLE t (n INTEGER)"); + assert_eq!(unsafe { elephc_pdo_exec(rw, ddl.as_ptr()) }, 0); + elephc_pdo_close(rw); + + // Reopening with sqlite_open_flags=1 (SQLITE_OPEN_READONLY) must reject a + // write against the now-existing file. + let ro = unsafe { + elephc_pdo_open_persistent( + dsn.as_ptr(), + 0, + 1, + std::ptr::null(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null(), + ) + }; + assert!(ro > 0, "read-only open failed"); + let ins = cs("INSERT INTO t VALUES (1)"); + assert_eq!( + unsafe { elephc_pdo_exec(ro, ins.as_ptr()) }, + -1, + "write should be rejected on a read-only handle" + ); + elephc_pdo_close(ro); + let _ = std::fs::remove_file(&path); + } + + /// v17 (P2-9): a `file:` URI-DSN body enables `SQLITE_OPEN_URI`, so a + /// `mode=ro` query parameter takes effect (SQLite docs: `mode=` overrides the + /// flags passed to `sqlite3_open_v2`) and fails to open a nonexistent + /// database rather than silently creating a new file at the literal + /// (unparsed) `file:...?mode=ro` path, which the pre-fix code did. + #[test] + fn sqlite_file_uri_dsn_mode_ro_nonexistent_fails() { + let path = std::env::temp_dir().join(format!( + "elephc_pdo_test_uri_ro_{}_{}.sqlite", + std::process::id(), + next_id() + )); + let _ = std::fs::remove_file(&path); + let dsn = cs(&format!("sqlite:file:{}?mode=ro", path.display())); + let id = unsafe { + elephc_pdo_open_persistent( + dsn.as_ptr(), + 0, + 0, + std::ptr::null(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null(), + ) + }; + assert_eq!( + id, -1, + "mode=ro URI DSN against a nonexistent file should fail to open, not create it" + ); + assert!(!path.exists(), "a literal file must not have been created"); + } + + /// v17 (P2-16): `elephc_pdo_stmt_readonly` reports `1` for a SELECT statement + /// and `0` for an INSERT, backing + /// `PDOStatement::getAttribute(Pdo\Sqlite::ATTR_READONLY_STATEMENT)` as a live + /// `sqlite3_stmt_readonly` read. An unknown handle reports `0`. + #[test] + fn sqlite_stmt_readonly_reports_select_vs_write() { let dsn = cs("sqlite::memory:"); let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; assert!(conn > 0, "open failed"); - - let ddl = cs("CREATE TABLE blobs (data BLOB)"); + let ddl = cs("CREATE TABLE t (n INTEGER)"); assert_eq!(unsafe { elephc_pdo_exec(conn, ddl.as_ptr()) }, 0); - let ins = cs("INSERT INTO blobs (data) VALUES (x'410042')"); - assert_eq!(unsafe { elephc_pdo_exec(conn, ins.as_ptr()) }, 1); + let sel = cs("SELECT n FROM t"); + let sel_stmt = unsafe { elephc_pdo_prepare(conn, sel.as_ptr(), 0) }; + assert!(sel_stmt > 0, "prepare failed"); + assert_eq!(elephc_pdo_stmt_readonly(sel_stmt), 1); - let sql = cs("SELECT data FROM blobs"); - let stmt = unsafe { elephc_pdo_prepare(conn, sql.as_ptr()) }; - assert!(stmt > 0, "prepare failed"); - assert_eq!(elephc_pdo_step(stmt), 1); - assert_eq!(elephc_pdo_column_type(stmt, 0), 4); - assert_eq!(elephc_pdo_column_data_len(stmt, 0), 3); - let ptr = elephc_pdo_column_data_ptr(stmt, 0); - assert_eq!(unsafe { read_bytes(ptr, 3) }, b"A\0B"); - assert_eq!(elephc_pdo_column_data_byte(stmt, 0, 0), 65); - assert_eq!(elephc_pdo_column_data_byte(stmt, 0, 1), 0); - assert_eq!(elephc_pdo_column_data_byte(stmt, 0, 2), 66); - assert_eq!(elephc_pdo_column_data_byte(stmt, 0, 3), 0); + let ins = cs("INSERT INTO t VALUES (1)"); + let ins_stmt = unsafe { elephc_pdo_prepare(conn, ins.as_ptr(), 0) }; + assert!(ins_stmt > 0, "prepare failed"); + assert_eq!(elephc_pdo_stmt_readonly(ins_stmt), 0); - assert_eq!(elephc_pdo_finalize(stmt), 1); + assert_eq!(elephc_pdo_stmt_readonly(999_999), 0); + + assert_eq!(elephc_pdo_finalize(sel_stmt), 1); + assert_eq!(elephc_pdo_finalize(ins_stmt), 1); elephc_pdo_close(conn); } - /// SQLite persistent opens reuse a process-local connection by DSN and a - /// close call leaves that pooled connection available to the next open. + /// Verifies PHP 8.5 SQLite transaction modes are stored, validated, and used by begin. #[test] - fn sqlite_persistent_pool_reuses_connection_after_close() { + fn sqlite_transaction_mode_round_trips_and_rejects_invalid_values() { let dsn = cs("sqlite::memory:"); - let first = unsafe { elephc_pdo_open_persistent(dsn.as_ptr(), 1) }; - assert!(first > 0, "open failed"); - - let ddl = cs("CREATE TABLE persistent_pool (n INTEGER)"); - assert_eq!(unsafe { elephc_pdo_exec(first, ddl.as_ptr()) }, 0); - let ins = cs("INSERT INTO persistent_pool VALUES (77)"); - assert_eq!(unsafe { elephc_pdo_exec(first, ins.as_ptr()) }, 1); - elephc_pdo_close(first); + let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; + assert!(conn > 0, "open failed"); + assert_eq!(elephc_pdo_transaction_mode(conn), 0); + assert_eq!(elephc_pdo_set_transaction_mode(conn, 1), 1); + assert_eq!(elephc_pdo_transaction_mode(conn), 1); + assert_eq!(elephc_pdo_begin(conn), 1); + assert_eq!(elephc_pdo_in_transaction(conn), 1); + assert_eq!(elephc_pdo_rollback(conn), 1); + assert_eq!(elephc_pdo_set_transaction_mode(conn, 3), 0); + assert_eq!(elephc_pdo_transaction_mode(conn), 1); + assert_eq!(elephc_pdo_transaction_mode(999_999), -1); + elephc_pdo_close(conn); + } - let second = unsafe { elephc_pdo_open_persistent(dsn.as_ptr(), 1) }; - assert_eq!(second, first); - let sql = cs("SELECT n FROM persistent_pool"); - let stmt = unsafe { elephc_pdo_prepare(second, sql.as_ptr()) }; + /// Verifies PHP 8.5 SQLite statement busy and explain attributes use live SQLite state. + #[test] + fn sqlite_statement_busy_and_explain_attributes_are_live() { + let dsn = cs("sqlite::memory:"); + let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; + assert!(conn > 0, "open failed"); + let sql = cs("SELECT 1"); + let stmt = unsafe { elephc_pdo_prepare(conn, sql.as_ptr(), 0) }; assert!(stmt > 0, "prepare failed"); + assert_eq!(elephc_pdo_stmt_busy(stmt), 0); + assert_eq!(elephc_pdo_stmt_explain_mode(stmt), 0); + assert_eq!(elephc_pdo_stmt_set_explain_mode(stmt, 1), 1); + assert_eq!(elephc_pdo_stmt_explain_mode(stmt), 1); + assert_eq!(elephc_pdo_stmt_set_explain_mode(stmt, 3), 0); assert_eq!(elephc_pdo_step(stmt), 1); - assert_eq!(elephc_pdo_column_int(stmt, 0), 77); + assert_eq!(elephc_pdo_stmt_busy(stmt), 1); + assert_eq!(elephc_pdo_reset(stmt), 1); + assert_eq!(elephc_pdo_stmt_busy(stmt), 0); assert_eq!(elephc_pdo_finalize(stmt), 1); + elephc_pdo_close(conn); + } + + /// Fixture test: the SQLite→SQLSTATE mapping mirrors php-src's + /// `pdo_sqlite_error` table (`ext/pdo_sqlite/sqlite_driver.c`). Pinning the + /// pairs here as data (rather than exercising them only indirectly through a + /// live error) turns any future drift from upstream's table into a + /// mechanical, one-line diff instead of a silent behavior change. + #[test] + fn sqlite_sqlstate_fixture_matches_php_src() { + use libsqlite3_sys as ffi; + let cases = [ + (ffi::SQLITE_OK, "00000"), + (ffi::SQLITE_ERROR, "HY000"), + (ffi::SQLITE_CONSTRAINT, "23000"), + (ffi::SQLITE_BUSY, "HY000"), + (ffi::SQLITE_LOCKED, "HY000"), + (ffi::SQLITE_READONLY, "HY000"), + (ffi::SQLITE_PERM, "HY000"), + (ffi::SQLITE_NOTADB, "HY000"), + (ffi::SQLITE_NOTFOUND, "42S02"), + (ffi::SQLITE_INTERRUPT, "01002"), + (ffi::SQLITE_NOLFS, "HYC00"), + (ffi::SQLITE_TOOBIG, "22001"), + ]; + for (code, expected) in cases { + assert_eq!( + sqlite::sqlite_sqlstate(code), + expected, + "sqlite result code {code} mapped wrong" + ); + } } /// Placeholder translation: `?` → `$1`, `:name` → `$N` (deduped), with /// `'…'` literals and the `::` cast operator left untouched. #[test] fn pg_translate_placeholders() { - let (sql, map) = pg::translate_placeholders( + let (sql, map, mixed) = pg::translate_placeholders( "SELECT * FROM t WHERE a = ? AND b = :b AND c = :b AND d = 'x?:y' AND e = id::text", ); assert_eq!( @@ -907,6 +6395,242 @@ mod tests { "SELECT * FROM t WHERE a = $1 AND b = $2 AND c = $2 AND d = 'x?:y' AND e = id::text" ); assert_eq!(map.get("b"), Some(&2)); + assert!(mixed, "a positional ? and a named :b were both used"); + } + + /// A `--` line comment's `?` is left untouched; only the trailing real + /// placeholder is translated. + #[test] + fn pg_translate_placeholders_line_comment() { + let (sql, map, mixed) = pg::translate_placeholders("-- x = ?\nSELECT ?"); + assert_eq!(sql, "-- x = ?\nSELECT $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A `/* ... */` block comment's `?` and `:a` are left untouched; only the + /// trailing real placeholder is translated. + #[test] + fn pg_translate_placeholders_block_comment() { + let (sql, map, mixed) = pg::translate_placeholders("/* ? :a */ SELECT ?"); + assert_eq!(sql, "/* ? :a */ SELECT $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A `'...'` single-quoted literal's `?`/`::` are preserved verbatim. + #[test] + fn pg_translate_placeholders_single_quote() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT '?::x', ?"); + assert_eq!(sql, "SELECT '?::x', $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// An unterminated PostgreSQL quote backtracks to ordinary text, so a later + /// placeholder is still visible exactly as in php-src's re2c scanner. + #[test] + fn pg_translate_placeholders_unterminated_quote_backtracks() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT 'unterminated ?"); + assert_eq!(sql, "SELECT 'unterminated $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// An unterminated PostgreSQL block-comment opener does not consume to EOF; + /// the positional marker after it remains a bind slot. + #[test] + fn pg_translate_placeholders_unterminated_block_comment_backtracks() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT /* unterminated ?"); + assert_eq!(sql, "SELECT /* unterminated $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A `"..."` double-quoted identifier's `?` is preserved verbatim. + #[test] + fn pg_translate_placeholders_double_quote() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT \"we?rd\" , ?"); + assert_eq!(sql, "SELECT \"we?rd\" , $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A `$$...$$` (empty-tag) dollar-quoted string's body is preserved + /// verbatim, including the `?` inside it. + #[test] + fn pg_translate_placeholders_dollar_quote_empty_tag() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT $$a ? b$$, ?"); + assert_eq!(sql, "SELECT $$a ? b$$, $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A `$tag$...$tag$` (named-tag) dollar-quoted string's body is preserved + /// verbatim, and the trailing `:n` still translates to `$1`. + #[test] + fn pg_translate_placeholders_dollar_quote_tagged() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT $x$ p?q $x$ , :n"); + assert_eq!(sql, "SELECT $x$ p?q $x$ , $1"); + assert_eq!(map.get("n"), Some(&1)); + assert!(!mixed); + } + + /// A `$` immediately followed by a digit (e.g. a literal `$1` in the input) + /// can never open a dollar-quote tag and is emitted verbatim, distinct from + /// the real placeholder translation. + #[test] + fn pg_translate_placeholders_dollar_digit_not_a_tag() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT $2foo, ?"); + assert_eq!(sql, "SELECT $2foo, $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// `??` is PostgreSQL's jsonb operator escape: it collapses to a single + /// literal `?` and allocates no placeholder slot. + #[test] + fn pg_translate_placeholders_jsonb_escape() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT d ?? 'k'"); + assert_eq!(sql, "SELECT d ? 'k'"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// An `E'...'` escape-string is not terminated early by its backslash- + /// escaped quote; the trailing `?` still translates. + #[test] + fn pg_translate_placeholders_e_string_backslash_escape() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT E'it\\'s', ?"); + assert_eq!(sql, "SELECT E'it\\'s', $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// Repeated named placeholders dedupe to the same index. + #[test] + fn pg_translate_placeholders_named_dedup() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT :a, :a, :b"); + assert_eq!(sql, "SELECT $1, $1, $2"); + assert_eq!(map.get("a"), Some(&1)); + assert_eq!(map.get("b"), Some(&2)); + assert!(!mixed); + } + + /// The `::` cast operator is left untouched (not read as a named + /// placeholder), while a real `?` still translates. + #[test] + fn pg_translate_placeholders_cast_operator() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT x::int, ?"); + assert_eq!(sql, "SELECT x::int, $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A SQL text using both a positional `?` and a named `:a` sets the + /// `mixed` flag, which `PgConn::prepare()` uses to reject the statement + /// with `HY093` before ever asking the server to prepare it. + #[test] + fn pg_translate_placeholders_mixed_flag() { + let (_, _, mixed) = pg::translate_placeholders("SELECT ?, :a"); + assert!(mixed); + } + + /// BUG 1 regression: multi-byte UTF-8 bytes inside a `'...'` string literal + /// must round-trip byte-for-byte. A per-byte `u8 as char` cast would + /// double-encode any byte >= 0x80 (a UTF-8 continuation byte reinterpreted + /// as a Latin-1 codepoint), corrupting `café`/`Zürich` before the SQL ever + /// reaches the server. + #[test] + fn pg_translate_placeholders_utf8_string_literal_preserved() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT 'café', ? , 'Zürich'"); + assert_eq!(sql, "SELECT 'café', $1 , 'Zürich'"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// BUG 1 regression: a multi-byte UTF-8 byte outside any recognized quoted + /// region (the ordinary/unquoted scanning path) must also round-trip + /// unmangled. + #[test] + fn pg_translate_placeholders_utf8_outside_quotes_preserved() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT résumé, ?"); + assert_eq!(sql, "SELECT résumé, $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// BUG 2 regression: a `:name`-shaped token immediately preceded by an + /// alphanumeric byte is not a bind placeholder (matching php-src's + /// `pdo_sql_parser.re`) — most importantly a PostgreSQL array slice like + /// `data[1:5]`, which must not be misread as a named parameter `:5`. + #[test] + fn pg_translate_placeholders_array_slice_not_named_param() { + let (sql, map, mixed) = + pg::translate_placeholders("SELECT data[1:5] FROM t WHERE id = ?"); + assert_eq!(sql, "SELECT data[1:5] FROM t WHERE id = $1"); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// F-PARSE-01 (audit corpus #10), PostgreSQL half — the exact mirror of + /// `my_translate_placeholders_triple_colon_not_named`. php-src's pgsql scanner + /// rule is `MULTICHAR = [:]{2,}` (`pgsql_sql_parser.re:35`), and re2c matches by + /// maximal munch: an ODD run of colons is ONE verbatim text token, not a `::` + /// cast pair followed by a fresh named placeholder. Consuming colons two at a + /// time left the third colon of `:::c` to be re-scanned as `:c`, conjuring a + /// phantom named bind real PHP never emits — and with it a bind-count + /// disagreement between elephc and the server. Only the trailing `?` is a slot, + /// so the map stays empty and the statement is not "mixed". + #[test] + fn pg_translate_placeholders_triple_colon_not_named() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT a WHERE b :::c AND d = ?"); + assert_eq!(sql, "SELECT a WHERE b :::c AND d = $1"); + assert!(map.is_empty(), "`:::c` must not allocate a named bind slot"); + assert!(!mixed); + } + + /// The even-length counterpart of the greedy-run rule above (pg side). A 4-colon + /// run was already emitted verbatim by the old pairwise loop (two exact pairs, + /// nothing left over), so this pins that rewriting the `:` arm to consume the + /// WHOLE run did not regress the case that used to work: `::::c` still yields no + /// named bind and the lone `?` stays the only slot. Together with + /// `pg_translate_placeholders_cast_operator` (the 2-colon case) the three + /// lengths 2/3/4 are covered. + #[test] + fn pg_translate_placeholders_quadruple_colon_not_named() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT a WHERE b ::::c AND d = ?"); + assert_eq!(sql, "SELECT a WHERE b ::::c AND d = $1"); + assert!(map.is_empty(), "`::::c` must not allocate a named bind slot"); + assert!(!mixed); + } + + /// F-PARSE-02 (audit corpus #12): a dollar-quote TAG may contain non-ASCII + /// bytes. php-src's pgsql scanner spells the classes `DOLQ_START = + /// [A-Za-z\200-\377_]` and `DOLQ_CONT = [A-Za-z\200-\377_0-9]` + /// (`pgsql_sql_parser.re:32-33`), matching PostgreSQL's own lexer, so + /// `$café$ … $café$` is a real dollar-quoted string. Gating the tag on + /// `is_ascii_alphabetic()` meant the quote never opened, the body fell through + /// to the ordinary scanner, and the `?` INSIDE the string literal was rewritten + /// into a bind — corrupting the SQL text and inventing a parameter. The ASCII + /// contrast (`$cafe$`) is asserted alongside so the test shows the delta is the + /// non-ASCII tag byte and nothing else: both must preserve the body verbatim and + /// translate only the trailing `?` to `$1`. + #[test] + fn pg_translate_placeholders_non_ascii_dollar_quote_tag() { + let (sql, map, mixed) = pg::translate_placeholders("SELECT $café$ a ? b $café$, ?"); + assert_eq!(sql, "SELECT $café$ a ? b $café$, $1"); + assert!( + map.is_empty(), + "the `?` inside a `$café$` literal must not become a bind" + ); + assert!(!mixed); + + let (ascii_sql, ascii_map, ascii_mixed) = + pg::translate_placeholders("SELECT $cafe$ a ? b $cafe$, ?"); + assert_eq!(ascii_sql, "SELECT $cafe$ a ? b $cafe$, $1"); + assert!(ascii_map.is_empty()); + assert!(!ascii_mixed); } /// A `pgsql:` DSN parses into a libpq connection string. @@ -917,52 +6641,170 @@ mod tests { assert!(s.contains("dbname='app'"), "got: {s}"); } + /// F-PG-03 / F-CORE-10: php-src's pgsql handle factory bounds EVERY connect — + /// `pgsql_driver.c:1350,1373,1381` default `connect_timeout` to 30 s and always + /// append it to the conninfo — so a black-holed host fails in seconds rather + /// than hanging. elephc forwarded no timeout at all, and the pure-Rust + /// `postgres` client has no application-level connect bound of its own, so the + /// connect could hang for minutes. The default is now folded into the conninfo + /// whenever the caller supplied none (the prelude folds `PDO::ATTR_TIMEOUT` into + /// the DSN under this very same key, so both seams land on this one check). + #[test] + fn pg_dsn_defaults_connect_timeout_to_30s() { + let s = pg::parse_dsn("pgsql:host=localhost;dbname=app").unwrap(); + assert!( + s.contains("connect_timeout='30'"), + "an unbounded connect must be bounded at php-src's 30 s; got: {s}" + ); + } + + /// The other half of F-PG-03: a `connect_timeout` the caller spelled out (in the + /// DSN body, or via `PDO::ATTR_TIMEOUT`, which the prelude folds into the DSN + /// under the same key) WINS — the 30 s default only fills a gap. This is a + /// deliberate, documented divergence from php-src, which overwrites the + /// DSN-supplied value with its own; silently ignoring an explicit timeout would + /// be the more surprising behavior. + #[test] + fn pg_dsn_explicit_connect_timeout_wins_over_the_default() { + let s = pg::parse_dsn("pgsql:host=localhost;dbname=app;connect_timeout=5").unwrap(); + assert!(s.contains("connect_timeout='5'"), "got: {s}"); + assert!( + !s.contains("connect_timeout='30'"), + "the default must not be appended on top of an explicit value; got: {s}" + ); + } + + /// Libpq resolves a bare `pgsql:` against the operating-system username rather + /// than rejecting it before connection. The native resolver mirrors that and + /// still applies php-src's default connect timeout. + #[test] + fn pg_dsn_empty_body_uses_libpq_os_user_default() { + let connection = pg::parse_dsn("pgsql:").expect("OS user supplies libpq default"); + assert!(connection.contains("user='")); + assert!(connection.contains("connect_timeout='30'")); + } + /// Full PostgreSQL round-trip against a live server. Ignored by default; run - /// with `ELEPHC_PG_TEST_DSN` set, e.g. - /// `ELEPHC_PG_TEST_DSN='pgsql:host=localhost;port=55432;dbname=testdb;user=test;password=test'`. + /// with `ELEPHC_PG_TEST_DSN` — or, since F-QUAL-06, the codegen suite's own + /// `ELEPHC_PG_DSN` — set, e.g. + /// `ELEPHC_PG_DSN='pgsql:host=localhost;port=55432;dbname=testdb;user=test;password=test'`. + /// Also covers the v7 additions: `elephc_pdo_bind_bool`, `elephc_pdo_bind_blob` + /// (an embedded-NUL blob and a null-pointer→NULL bind), `elephc_pdo_sqlstate`/ + /// `elephc_pdo_stmt_sqlstate` (`"00000"` after a success, a real SQLSTATE after + /// a forced duplicate-key error, and a reset back to `"00000"` after the next + /// successful `prepare()`), and `elephc_pdo_server_version`. #[test] #[ignore] fn pg_round_trip() { - let Ok(dsn) = std::env::var("ELEPHC_PG_TEST_DSN") else { + let Some(dsn) = live_dsn("ELEPHC_PG_TEST_DSN", "ELEPHC_PG_DSN") else { return; }; let dsn = cs(&dsn); let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; - assert!(conn > 0, "pg open failed"); + assert!( + conn > 0, + "pg open failed: {}", + unsafe { read(elephc_pdo_last_open_error()) } + ); + + let version = unsafe { read(elephc_pdo_server_version(conn)) }; + assert!(!version.is_empty(), "server_version was empty"); let drop = cs("DROP TABLE IF EXISTS pdo_rt"); unsafe { elephc_pdo_exec(conn, drop.as_ptr()) }; - let ddl = - cs("CREATE TABLE pdo_rt (id SERIAL PRIMARY KEY, name TEXT, score DOUBLE PRECISION)"); + let ddl = cs( + "CREATE TABLE pdo_rt (id SERIAL PRIMARY KEY, name TEXT, score DOUBLE PRECISION, flag BOOLEAN, data BYTEA)", + ); unsafe { elephc_pdo_exec(conn, ddl.as_ptr()) }; + assert_eq!(unsafe { read(elephc_pdo_sqlstate(conn)) }, "00000"); - let ins = cs("INSERT INTO pdo_rt (name, score) VALUES (:n, :s)"); - let stmt = unsafe { elephc_pdo_prepare(conn, ins.as_ptr()) }; + let ins = cs("INSERT INTO pdo_rt (name, score, flag, data) VALUES (:n, :s, :f, :d)"); + let stmt = unsafe { elephc_pdo_prepare(conn, ins.as_ptr(), 0) }; assert!(stmt > 0, "pg prepare failed"); let n = cs(":n"); let ni = unsafe { elephc_pdo_bind_parameter_index(stmt, n.as_ptr()) }; let s = cs(":s"); let si = unsafe { elephc_pdo_bind_parameter_index(stmt, s.as_ptr()) }; + let f = cs(":f"); + let fi = unsafe { elephc_pdo_bind_parameter_index(stmt, f.as_ptr()) }; + let d = cs(":d"); + let di = unsafe { elephc_pdo_bind_parameter_index(stmt, d.as_ptr()) }; let ada = cs("Ada"); - unsafe { elephc_pdo_bind_text(stmt, ni, ada.as_ptr()) }; + unsafe { elephc_pdo_bind_text(stmt, ni, ada.as_ptr(), ada.as_bytes().len() as i64) }; elephc_pdo_bind_double(stmt, si, 9.5); + elephc_pdo_bind_bool(stmt, fi, 1); + let blob = b"A\0B"; + unsafe { + elephc_pdo_bind_blob(stmt, di, blob.as_ptr() as *const c_char, blob.len() as i64) + }; assert_eq!(elephc_pdo_step(stmt), 0); elephc_pdo_finalize(stmt); + assert_eq!(unsafe { read(elephc_pdo_sqlstate(conn)) }, "00000"); let lid = unsafe { elephc_pdo_last_insert_id(conn, std::ptr::null()) }; assert_eq!(lid, 1); - let sel = cs("SELECT id, name, score FROM pdo_rt WHERE id = ?"); - let q = unsafe { elephc_pdo_prepare(conn, sel.as_ptr()) }; + // Bug 1 regression coverage: a null-pointer blob bind stores SQL NULL + // rather than an empty blob. + let ins2 = cs("INSERT INTO pdo_rt (name, score, flag, data) VALUES (:n, :s, :f, :d)"); + let stmt2 = unsafe { elephc_pdo_prepare(conn, ins2.as_ptr(), 0) }; + assert!(stmt2 > 0, "pg prepare failed"); + let ni2 = unsafe { elephc_pdo_bind_parameter_index(stmt2, n.as_ptr()) }; + let si2 = unsafe { elephc_pdo_bind_parameter_index(stmt2, s.as_ptr()) }; + let fi2 = unsafe { elephc_pdo_bind_parameter_index(stmt2, f.as_ptr()) }; + let di2 = unsafe { elephc_pdo_bind_parameter_index(stmt2, d.as_ptr()) }; + let grace = cs("Grace"); + unsafe { elephc_pdo_bind_text(stmt2, ni2, grace.as_ptr(), grace.as_bytes().len() as i64) }; + elephc_pdo_bind_double(stmt2, si2, 1.0); + elephc_pdo_bind_bool(stmt2, fi2, 0); + unsafe { elephc_pdo_bind_blob(stmt2, di2, std::ptr::null(), 0) }; + assert_eq!(elephc_pdo_step(stmt2), 0); + elephc_pdo_finalize(stmt2); + + let sel = cs("SELECT id, name, score, flag, data FROM pdo_rt WHERE id = ?"); + let q = unsafe { elephc_pdo_prepare(conn, sel.as_ptr(), 0) }; elephc_pdo_bind_int(q, 1, 1); assert_eq!(elephc_pdo_step(q), 1); assert_eq!(elephc_pdo_column_int(q, 0), 1); assert_eq!(unsafe { read(elephc_pdo_column_name(q, 1)) }, "name"); - assert_eq!(unsafe { read(elephc_pdo_column_text(q, 1)) }, "Ada"); + // v24/F-QUAL-03: the NUL-stripping `elephc_pdo_column_text` is gone; a text + // column is read through the same byte-exact len+ptr pair the prelude uses. + let name_len = elephc_pdo_column_data_len(q, 1); + let name_ptr = elephc_pdo_column_data_ptr(q, 1); + assert_eq!(unsafe { read_bytes(name_ptr, name_len) }, b"Ada"); assert_eq!(elephc_pdo_column_double(q, 2), 9.5); + assert_eq!(elephc_pdo_column_int(q, 3), 1); + assert_eq!(elephc_pdo_column_data_len(q, 4), 3); + let ptr = elephc_pdo_column_data_ptr(q, 4); + assert_eq!(unsafe { read_bytes(ptr, 3) }, b"A\0B"); assert_eq!(elephc_pdo_step(q), 0); elephc_pdo_finalize(q); + let sel2 = cs("SELECT data FROM pdo_rt WHERE id = 2"); + let q2 = unsafe { elephc_pdo_prepare(conn, sel2.as_ptr(), 0) }; + assert!(q2 > 0, "pg prepare failed"); + assert_eq!(elephc_pdo_step(q2), 1); + assert_eq!(elephc_pdo_column_type(q2, 0), 5, "null-pointer blob bind must read back as NULL"); + elephc_pdo_finalize(q2); + + // Bug 2 regression coverage: a forced duplicate-key error reports a + // non-"00000" SQLSTATE at both the connection and statement level, and + // the following successful prepare() resets it back to "00000". + let dup = cs("INSERT INTO pdo_rt (id, name) VALUES (1, 'dup')"); + let dup_stmt = unsafe { elephc_pdo_prepare(conn, dup.as_ptr(), 0) }; + assert!(dup_stmt > 0, "pg prepare failed"); + assert_eq!(elephc_pdo_step(dup_stmt), -1); + let dup_state = unsafe { read(elephc_pdo_sqlstate(conn)) }; + assert_ne!(dup_state, "00000", "expected a real SQLSTATE, got: {dup_state}"); + assert_eq!(unsafe { read(elephc_pdo_stmt_sqlstate(dup_stmt)) }, dup_state); + elephc_pdo_finalize(dup_stmt); + + let sel3 = cs("SELECT 1"); + let ok_stmt = unsafe { elephc_pdo_prepare(conn, sel3.as_ptr(), 0) }; + assert!(ok_stmt > 0, "pg prepare failed"); + assert_eq!(unsafe { read(elephc_pdo_sqlstate(conn)) }, "00000"); + elephc_pdo_finalize(ok_stmt); + let cleanup = cs("DROP TABLE pdo_rt"); unsafe { elephc_pdo_exec(conn, cleanup.as_ptr()) }; elephc_pdo_close(conn); @@ -973,8 +6815,9 @@ mod tests { /// `::` left untouched. #[test] fn my_translate_placeholders() { - let (sql, map, order) = my::translate_placeholders( + let (sql, map, order, mixed) = my::translate_placeholders( "SELECT * FROM t WHERE a = ? AND b = :b AND c = :b AND d = 'x?:y' AND e = id::text", + false, ); assert_eq!( sql, @@ -983,56 +6826,354 @@ mod tests { // `?`→slot 1, `:b`→slot 2 (reused for the second `:b`). assert_eq!(order, vec![1, 2, 2]); assert_eq!(map.get("b"), Some(&2)); + assert!(mixed, "a positional ? and a named :b were both used"); + } + + /// A `--` line comment's `?` is left untouched; only the trailing real + /// placeholder is translated. + #[test] + fn my_translate_placeholders_line_comment() { + let (sql, map, order, mixed) = my::translate_placeholders("-- ?\nSELECT ?", false); + assert_eq!(sql, "-- ?\nSELECT ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// `--` NOT followed by whitespace is not a MySQL comment (`a--b` is the + /// arithmetic `a - -b`), so a `?` after it is a real placeholder — matching + /// php-src's `mysql_sql_parser.re` COMMENTS rule (`"--"[ \t\v\f\r]`). + #[test] + fn my_translate_placeholders_double_dash_not_comment() { + let (sql, map, order, mixed) = my::translate_placeholders("SELECT a--b, ? FROM t", false); + assert_eq!(sql, "SELECT a--b, ? FROM t"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A bare `--` (no trailing whitespace) does not open a comment, so both the + /// positional `?` and the named `:c` after it are real placeholders — which + /// makes the statement mixed (HY093 at prepare). + #[test] + fn my_translate_placeholders_double_dash_keeps_mixed() { + let (_sql, map, order, mixed) = my::translate_placeholders("SELECT ?--:c", false); + assert_eq!(order, vec![1, 2]); + assert_eq!(map.get("c"), Some(&2)); + assert!(mixed); + } + + /// A `#` line comment's `?` is left untouched; only the trailing real + /// placeholder is translated. + #[test] + fn my_translate_placeholders_hash_comment() { + let (sql, map, order, mixed) = my::translate_placeholders("# ?\nSELECT ?", false); + assert_eq!(sql, "# ?\nSELECT ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A `/* ... */` block comment's `?` is left untouched; only the trailing + /// real placeholder is translated. + #[test] + fn my_translate_placeholders_block_comment() { + let (sql, map, order, mixed) = my::translate_placeholders("/* ? */ SELECT ?", false); + assert_eq!(sql, "/* ? */ SELECT ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A `"..."` double-quoted string literal's `?` is preserved verbatim (both + /// quote styles are string literals in MySQL's default `sql_mode`). + #[test] + fn my_translate_placeholders_double_quote_string() { + let (sql, map, order, mixed) = my::translate_placeholders("SELECT \"a?b\", ?", false); + assert_eq!(sql, "SELECT \"a?b\", ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// An unterminated MySQL quote falls back to ordinary text, leaving the + /// following question mark visible as a positional bind. + #[test] + fn my_translate_placeholders_unterminated_quote_backtracks() { + let (sql, map, order, mixed) = + my::translate_placeholders("SELECT 'unterminated ?", false); + assert_eq!(sql, "SELECT 'unterminated ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// An unterminated MySQL block comment does not hide a later positional + /// marker from PDO's placeholder scanner. + #[test] + fn my_translate_placeholders_unterminated_block_comment_backtracks() { + let (sql, map, order, mixed) = + my::translate_placeholders("SELECT /* unterminated ?", false); + assert_eq!(sql, "SELECT /* unterminated ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A backslash-escaped quote inside a `'...'` literal does not terminate + /// the string early. + #[test] + fn my_translate_placeholders_backslash_in_single_quote() { + let (sql, map, order, mixed) = my::translate_placeholders("SELECT 'a\\'b', ?", false); + assert_eq!(sql, "SELECT 'a\\'b', ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A backtick-quoted identifier's `?` is preserved verbatim. + #[test] + fn my_translate_placeholders_backtick_identifier() { + let (sql, map, order, mixed) = my::translate_placeholders("SELECT `we?rd`, ?", false); + assert_eq!(sql, "SELECT `we?rd`, ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A run of two `?` is not two positional placeholders (P1-a): MySQL has no + /// `?` operators to unescape the way PostgreSQL's jsonb `?`/`?|`/`?&` do, so + /// the run is emitted verbatim as a text token with no slot allocated — + /// `order` stays `[1]`, counting only the lone trailing `?`. The mirror of + /// the PostgreSQL `pg_translate_placeholders_jsonb_escape` coverage; the + /// load-bearing property both pin is "`??` allocates no bind slot". + #[test] + fn my_translate_placeholders_double_question_no_slot() { + let (sql, map, order, mixed) = my::translate_placeholders("SELECT a ?? b, ?", false); + assert_eq!(sql, "SELECT a ?? b, ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// A named placeholder reused twice shares one slot in both `order` and + /// the name map. + #[test] + fn my_translate_placeholders_named_reuse() { + let (sql, map, order, mixed) = my::translate_placeholders("SELECT :a, :a", false); + assert_eq!(sql, "SELECT ?, ?"); + assert_eq!(order, vec![1, 1]); + assert_eq!(map.get("a"), Some(&1)); + assert!(!mixed); + } + + /// A SQL text using both a positional `?` and a named `:a` sets the + /// `mixed` flag, which `MyConn::prepare()` uses to reject the statement + /// with `HY093` before ever asking the server to prepare it. + #[test] + fn my_translate_placeholders_mixed_flag() { + let (_, _, _, mixed) = my::translate_placeholders("SELECT ?, :a", false); + assert!(mixed); + } + + /// BUG 1 regression: multi-byte UTF-8 bytes inside a `'...'` string + /// literal must round-trip byte-for-byte. A per-byte `u8 as char` cast + /// would double-encode any byte >= 0x80, corrupting `café` before the SQL + /// ever reaches the server. + #[test] + fn my_translate_placeholders_utf8_string_literal_preserved() { + let (sql, map, order, mixed) = my::translate_placeholders("SELECT 'café', ?", false); + assert_eq!(sql, "SELECT 'café', ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// BUG 2 regression: a `:name`-shaped token immediately preceded by an + /// alphanumeric byte is not a bind placeholder (matching php-src's + /// `pdo_sql_parser.re`), so it is left untouched and allocates no slot — + /// only the real trailing `?` does. + #[test] + fn my_translate_placeholders_colon_after_alnum_not_named() { + let (sql, map, order, mixed) = my::translate_placeholders("SELECT a:b, ?", false); + assert_eq!(sql, "SELECT a:b, ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty()); + assert!(!mixed); + } + + /// F-PARSE-01 (audit corpus #10): an ODD run of colons is one verbatim token, + /// not a `::` pair followed by a fresh named placeholder. php-src's + /// `MULTICHAR = [:]{2,}` rule is re2c-greedy — maximal munch swallows the whole + /// contiguous run — so `:::c` emits no bind at all. Consuming colons two at a + /// time left the third colon of the run to be re-scanned as `:c`, conjuring a + /// phantom named bind real PHP never emits (and, with it, a bind-count + /// disagreement between elephc and the server). Only the trailing `?` is a slot. + #[test] + fn my_translate_placeholders_triple_colon_not_named() { + let (sql, map, order, mixed) = + my::translate_placeholders("SELECT a WHERE b :::c AND d = ?", false); + assert_eq!(sql, "SELECT a WHERE b :::c AND d = ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty(), "`:::c` must not allocate a named bind slot"); + assert!(!mixed); + } + + /// The even-length counterpart of the greedy-run rule above. A 4-colon run was + /// already emitted verbatim by the old pairwise loop (two exact pairs, nothing + /// left over), so this pins that rewriting the `:` arm to consume the WHOLE run + /// did not regress the case that used to work — `::::c` still yields no named + /// bind, and the lone `?` stays the only slot. + #[test] + fn my_translate_placeholders_quadruple_colon_not_named() { + let (sql, map, order, mixed) = + my::translate_placeholders("SELECT a WHERE b ::::c AND d = ?", false); + assert_eq!(sql, "SELECT a WHERE b ::::c AND d = ?"); + assert_eq!(order, vec![1]); + assert!(map.is_empty(), "`::::c` must not allocate a named bind slot"); + assert!(!mixed); + } + + /// F-PARSE-07 precondition: both driver scanners must flag the SAME statement — + /// one that mixes a positional `?` with a named `:name` — as `mixed`, since that + /// single flag is what `PgConn::prepare()` and `MyConn::prepare()` each turn into + /// the identical HY093 rejection php-src raises for a mixed-parameter statement. + /// The finding itself (the two drivers reported DIFFERENT native codes in + /// `errorInfo()[1]` for that one logical error: my = 0, pg = 1) cannot be + /// asserted here: the native code is a field on a live `MyConn`/`PgConn`, which + /// only a connected server can produce. What is testable without a server — that + /// the two scanners agree the statement is rejectable at all — is pinned here. + #[test] + fn mixed_placeholders_flagged_by_both_scanners() { + let sql = "SELECT * FROM t WHERE a = ? AND b = :b"; + let (_, _, pg_mixed) = pg::translate_placeholders(sql); + let (_, _, _, my_mixed) = my::translate_placeholders(sql, false); + assert!(pg_mixed, "the pg scanner must flag the mixed statement"); + assert!(my_mixed, "the my scanner must flag the mixed statement"); } /// Full MySQL/MariaDB round-trip against a live server. Ignored by default; run - /// with `ELEPHC_MY_TEST_DSN` set, e.g. - /// `ELEPHC_MY_TEST_DSN='mysql:host=localhost;port=33060;dbname=testdb;user=test;password=test'`. + /// with `ELEPHC_MY_TEST_DSN` — or, since F-QUAL-06, the codegen suite's own + /// `ELEPHC_MY_DSN` — set, e.g. + /// `ELEPHC_MY_DSN='mysql:host=localhost;port=33060;dbname=testdb;user=test;password=test'`. + /// Also covers the v7 additions: `elephc_pdo_bind_bool`, `elephc_pdo_bind_blob` + /// (an embedded-NUL blob and a null-pointer→NULL bind), `elephc_pdo_sqlstate`/ + /// `elephc_pdo_stmt_sqlstate` (`"00000"` after a success, a real SQLSTATE after + /// a forced duplicate-key error, and a reset back to `"00000"` after the next + /// successful `prepare()`), and `elephc_pdo_server_version`. #[test] #[ignore] fn my_round_trip() { - let Ok(dsn) = std::env::var("ELEPHC_MY_TEST_DSN") else { + let Some(dsn) = live_dsn("ELEPHC_MY_TEST_DSN", "ELEPHC_MY_DSN") else { return; }; let dsn = cs(&dsn); let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) }; - assert!(conn > 0, "mysql open failed"); + assert!( + conn > 0, + "mysql open failed: {}", + unsafe { read(elephc_pdo_last_open_error()) } + ); assert_eq!(unsafe { read(elephc_pdo_driver_name(conn)) }, "mysql"); + let version = unsafe { read(elephc_pdo_server_version(conn)) }; + assert!(!version.is_empty(), "server_version was empty"); + let drop = cs("DROP TABLE IF EXISTS pdo_rt"); unsafe { elephc_pdo_exec(conn, drop.as_ptr()) }; let ddl = cs( - "CREATE TABLE pdo_rt (id INTEGER PRIMARY KEY AUTO_INCREMENT, name TEXT, score DOUBLE)", + "CREATE TABLE pdo_rt (id INTEGER PRIMARY KEY AUTO_INCREMENT, name TEXT, score DOUBLE, flag TINYINT(1), data BLOB)", ); unsafe { elephc_pdo_exec(conn, ddl.as_ptr()) }; + assert_eq!(unsafe { read(elephc_pdo_sqlstate(conn)) }, "00000"); - let ins = cs("INSERT INTO pdo_rt (name, score) VALUES (:n, :s)"); - let stmt = unsafe { elephc_pdo_prepare(conn, ins.as_ptr()) }; + let ins = cs("INSERT INTO pdo_rt (name, score, flag, data) VALUES (:n, :s, :f, :d)"); + let stmt = unsafe { elephc_pdo_prepare(conn, ins.as_ptr(), 0) }; assert!(stmt > 0, "mysql prepare failed"); let n = cs(":n"); let ni = unsafe { elephc_pdo_bind_parameter_index(stmt, n.as_ptr()) }; let s = cs(":s"); let si = unsafe { elephc_pdo_bind_parameter_index(stmt, s.as_ptr()) }; + let f = cs(":f"); + let fi = unsafe { elephc_pdo_bind_parameter_index(stmt, f.as_ptr()) }; + let d = cs(":d"); + let di = unsafe { elephc_pdo_bind_parameter_index(stmt, d.as_ptr()) }; let ada = cs("Ada"); - unsafe { elephc_pdo_bind_text(stmt, ni, ada.as_ptr()) }; + unsafe { elephc_pdo_bind_text(stmt, ni, ada.as_ptr(), ada.as_bytes().len() as i64) }; elephc_pdo_bind_double(stmt, si, 9.5); + elephc_pdo_bind_bool(stmt, fi, 1); + let blob = b"A\0B"; + unsafe { + elephc_pdo_bind_blob(stmt, di, blob.as_ptr() as *const c_char, blob.len() as i64) + }; assert_eq!(elephc_pdo_step(stmt), 0); elephc_pdo_finalize(stmt); + assert_eq!(unsafe { read(elephc_pdo_sqlstate(conn)) }, "00000"); let lid = unsafe { elephc_pdo_last_insert_id(conn, std::ptr::null()) }; assert_eq!(lid, 1); - let sel = cs("SELECT id, name, score FROM pdo_rt WHERE id = ?"); - let q = unsafe { elephc_pdo_prepare(conn, sel.as_ptr()) }; + // Bug 1 regression coverage: a null-pointer blob bind stores SQL NULL + // rather than an empty blob. + let ins2 = cs("INSERT INTO pdo_rt (name, score, flag, data) VALUES (:n, :s, :f, :d)"); + let stmt2 = unsafe { elephc_pdo_prepare(conn, ins2.as_ptr(), 0) }; + assert!(stmt2 > 0, "mysql prepare failed"); + let ni2 = unsafe { elephc_pdo_bind_parameter_index(stmt2, n.as_ptr()) }; + let si2 = unsafe { elephc_pdo_bind_parameter_index(stmt2, s.as_ptr()) }; + let fi2 = unsafe { elephc_pdo_bind_parameter_index(stmt2, f.as_ptr()) }; + let di2 = unsafe { elephc_pdo_bind_parameter_index(stmt2, d.as_ptr()) }; + let grace = cs("Grace"); + unsafe { elephc_pdo_bind_text(stmt2, ni2, grace.as_ptr(), grace.as_bytes().len() as i64) }; + elephc_pdo_bind_double(stmt2, si2, 1.0); + elephc_pdo_bind_bool(stmt2, fi2, 0); + unsafe { elephc_pdo_bind_blob(stmt2, di2, std::ptr::null(), 0) }; + assert_eq!(elephc_pdo_step(stmt2), 0); + elephc_pdo_finalize(stmt2); + + let sel = cs("SELECT id, name, score, flag, data FROM pdo_rt WHERE id = ?"); + let q = unsafe { elephc_pdo_prepare(conn, sel.as_ptr(), 0) }; elephc_pdo_bind_int(q, 1, 1); assert_eq!(elephc_pdo_step(q), 1); assert_eq!(elephc_pdo_column_int(q, 0), 1); assert_eq!(unsafe { read(elephc_pdo_column_name(q, 1)) }, "name"); - assert_eq!(unsafe { read(elephc_pdo_column_text(q, 1)) }, "Ada"); + // v24/F-QUAL-03: the NUL-stripping `elephc_pdo_column_text` is gone; a text + // column is read through the same byte-exact len+ptr pair the prelude uses. + let name_len = elephc_pdo_column_data_len(q, 1); + let name_ptr = elephc_pdo_column_data_ptr(q, 1); + assert_eq!(unsafe { read_bytes(name_ptr, name_len) }, b"Ada"); assert_eq!(elephc_pdo_column_double(q, 2), 9.5); + assert_eq!(elephc_pdo_column_int(q, 3), 1); + assert_eq!(elephc_pdo_column_data_len(q, 4), 3); + let ptr = elephc_pdo_column_data_ptr(q, 4); + assert_eq!(unsafe { read_bytes(ptr, 3) }, b"A\0B"); assert_eq!(elephc_pdo_step(q), 0); elephc_pdo_finalize(q); + let sel2 = cs("SELECT data FROM pdo_rt WHERE id = 2"); + let q2 = unsafe { elephc_pdo_prepare(conn, sel2.as_ptr(), 0) }; + assert!(q2 > 0, "mysql prepare failed"); + assert_eq!(elephc_pdo_step(q2), 1); + assert_eq!(elephc_pdo_column_type(q2, 0), 5, "null-pointer blob bind must read back as NULL"); + elephc_pdo_finalize(q2); + + // Bug 2 regression coverage: a forced duplicate-key error reports a + // non-"00000" SQLSTATE at both the connection and statement level, and + // the following successful prepare() resets it back to "00000". + let dup = cs("INSERT INTO pdo_rt (id, name) VALUES (1, 'dup')"); + let dup_stmt = unsafe { elephc_pdo_prepare(conn, dup.as_ptr(), 0) }; + assert!(dup_stmt > 0, "mysql prepare failed"); + assert_eq!(elephc_pdo_step(dup_stmt), -1); + let dup_state = unsafe { read(elephc_pdo_sqlstate(conn)) }; + assert_ne!(dup_state, "00000", "expected a real SQLSTATE, got: {dup_state}"); + assert_eq!(unsafe { read(elephc_pdo_stmt_sqlstate(dup_stmt)) }, dup_state); + elephc_pdo_finalize(dup_stmt); + + let sel3 = cs("SELECT 1"); + let ok_stmt = unsafe { elephc_pdo_prepare(conn, sel3.as_ptr(), 0) }; + assert!(ok_stmt > 0, "mysql prepare failed"); + assert_eq!(unsafe { read(elephc_pdo_sqlstate(conn)) }, "00000"); + elephc_pdo_finalize(ok_stmt); + let cleanup = cs("DROP TABLE pdo_rt"); unsafe { elephc_pdo_exec(conn, cleanup.as_ptr()) }; elephc_pdo_close(conn); diff --git a/crates/elephc-pdo/src/my.rs b/crates/elephc-pdo/src/my.rs index 142604c144..c9bf27d560 100644 --- a/crates/elephc-pdo/src/my.rs +++ b/crates/elephc-pdo/src/my.rs @@ -11,23 +11,35 @@ //! Key details: //! - MySQL placeholders are positional `?`. PDO `:name` placeholders are rewritten //! to `?` at prepare time, with a per-`?` `order` recording which bound slot -//! feeds it, so a `:name` used several times binds the same value to each `?` -//! (PHP cannot mix `?` and `:name` in one statement, so the two cases never -//! interleave). +//! feeds it, so a `:name` used several times binds the same value to each `?`. +//! A scanner skips `--`/`#`/`/* */` comments and `'…'`/`"…"`/`` `…` `` quoted +//! regions (all with their driver-correct escape rules, string literals +//! following the connection's live `NO_BACKSLASH_ESCAPES` `sql_mode`) so a +//! `?`/`:name` inside any of those is never mistaken for a real placeholder, +//! and the scanner's placeholder count always agrees with the server's own. +//! PDO forbids mixing `?` and `:name` in one statement; `prepare()` rejects the +//! mix with `HY093` before ever asking the server to prepare it. //! - A statement is prepared server-side for column metadata, then executed -//! lazily on the first `step()`. The whole result set is materialized into typed -//! `Cell` values, so the column accessors read from owned data and per-value -//! NULL is reported through the SQLite-compatible type codes (1=int, 2=float, -//! 3=text, 4=blob, 5=null). +//! lazily on the first `step()`. Buffered statements retain typed `Cell` rows; +//! unbuffered statements move the client into a demand worker and retain only +//! the current row, while preserving multiple result-set boundaries. //! - Bound values cross the wire as their native `mysql::Value` (ints, doubles, //! text bytes); the server coerces text to the column type, so — unlike the //! PostgreSQL driver — no per-parameter type inference is needed. use std::collections::HashMap; +use std::fs::{self, OpenOptions}; +use std::io::{Error as IoError, ErrorKind, Write}; +use std::ops::{Deref, DerefMut}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::thread::JoinHandle; +use std::time::Duration; -use mysql::consts::ColumnType; +use mysql::consts::{CapabilityFlags, ColumnFlags, ColumnType}; use mysql::prelude::Queryable; -use mysql::{Conn, OptsBuilder, Statement, Value}; +use mysql::{Column, Conn, LocalInfileHandler, OptsBuilder, QueryResult, Statement, Value}; /// One materialized column value, already decoded to a PHP-friendly scalar. pub enum Cell { @@ -45,11 +57,22 @@ pub enum Bind { Int(i64), Float(f64), Text(String), + /// Text rendered with MySQL's national-character `N'…'` introducer on the + /// emulated-prepare path (`PDO::PARAM_STR_NATL`). Native prepares send the + /// same byte payload as ordinary text and let the server type the parameter. + NationalText(String), + /// Raw bytes, sent as-is (rather than through a lossy UTF-8 `String`) so a + /// BLOB-style parameter round-trips embedded NUL bytes and arbitrary binary + /// content unchanged. + Bytes(Vec), } /// How a result column's MySQL type should render as text — the temporal types /// need their own formatting; everything else decodes directly from the value. -#[derive(Clone, Copy)] +/// `PartialEq`/`Debug` are only needed for the unit test asserting the BIT/ +/// GEOMETRY classification below; deriving them unconditionally is simpler +/// than gating and costs nothing (both are trivial, non-`pub` derives). +#[derive(Clone, Copy, PartialEq, Debug)] enum ColKind { Binary, Date, @@ -58,15 +81,92 @@ enum ColKind { Other, } +/// The `character_set` value MySQL uses for the special `binary` pseudo-collation +/// (collation id 63, `binary` charset): a `VARBINARY`/`BINARY` column always +/// reports this character set regardless of the connection's own charset, and it +/// is the ONLY signal that distinguishes those columns from `VARCHAR`/`CHAR` — +/// both pairs share the same wire `ColumnType` (`MYSQL_TYPE_VAR_STRING` / +/// `MYSQL_TYPE_STRING`). +const MYSQL_BINARY_CHARSET: u16 = 63; + +/// The connect timeout applied when neither the DSN nor `PDO::ATTR_TIMEOUT` +/// supplies one (F-CORE-10). php-src's mysql handle factory reads +/// `connect_timeout = pdo_attr_lval(driver_options, PDO_ATTR_TIMEOUT, 30)` +/// (`mysql_driver.c:755`) and unconditionally feeds it to +/// `mysql_options(MYSQL_OPT_CONNECT_TIMEOUT, …)` (`mysql_driver.c:784`) — the 30 s +/// bound is ALWAYS in force, and `ATTR_TIMEOUT` only *changes* its value, never +/// removes it. Without this default, a plain `mysql:` connection to a black-holed +/// host fell back on the OS TCP timeout and could hang for minutes where real PHP +/// gives up after 30 s. +const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30; + +/// Connection-time pdo_mysql options packed by the generated PDO prelude. +#[derive(Debug, Clone)] +struct MyDriverOptions { + local_infile: bool, + local_infile_directory: Option, + compress: bool, + ignore_space: bool, + multi_statements: bool, + buffered_query: bool, + ssl_ca_path: Option, + ssl_cipher: Option, + server_public_key: Option, +} + +impl Default for MyDriverOptions { + /// Returns php-src/mysqlnd's default connection-option state. + fn default() -> Self { + Self { + local_infile: false, + local_infile_directory: None, + compress: false, + ignore_space: false, + multi_statements: true, + buffered_query: true, + ssl_ca_path: None, + ssl_cipher: None, + server_public_key: None, + } + } +} + impl ColKind { - /// Classifies a MySQL column type into the text-rendering bucket the decoder + /// Classifies a MySQL column into the text-rendering bucket the decoder /// needs (date-only, date+time, time-of-day, or value-driven). - fn from_column_type(ct: ColumnType) -> ColKind { - match ct { + /// + /// `MYSQL_TYPE_BIT` and `MYSQL_TYPE_GEOMETRY` (P0-D) are routed through + /// `Binary` alongside the BLOB types: both carry arbitrary non-UTF-8 bytes + /// (a `BIT(8)` column's high-bit values, WKB-encoded geometry), and without + /// this, `decode_value` would run them through the lossy + /// `String::from_utf8_lossy` path used for `Other`, corrupting the bytes + /// into U+FFFD replacement characters. This matches php-src's mysqlnd, + /// which returns both types as raw (binary-string) bytes. + /// + /// `VARBINARY`/`BINARY` (P1) arrive on the wire as the exact same + /// `ColumnType` as `VARCHAR`/`CHAR` (`MYSQL_TYPE_VAR_STRING`/ + /// `MYSQL_TYPE_STRING` respectively) — `ColumnType` alone cannot tell them + /// apart. The one distinguishing signal is the column's character set: a + /// `VARBINARY`/`BINARY` column is always tagged with charset 63 (the + /// `binary` collation), while a real `VARCHAR`/`CHAR` carries the + /// connection's text charset (e.g. utf8mb4 = 45/46/224...). Without this, + /// those columns fell to `Other` and every non-UTF-8 byte they held was + /// silently replaced with U+FFFD by the lossy decode path — matching + /// php-src's mysqlnd, which also keys off the charset-63 marker to return + /// `VARBINARY`/`BINARY` as raw bytes. + fn from_column(col: &Column) -> ColKind { + match col.column_type() { ColumnType::MYSQL_TYPE_TINY_BLOB | ColumnType::MYSQL_TYPE_BLOB | ColumnType::MYSQL_TYPE_MEDIUM_BLOB - | ColumnType::MYSQL_TYPE_LONG_BLOB => ColKind::Binary, + | ColumnType::MYSQL_TYPE_LONG_BLOB + | ColumnType::MYSQL_TYPE_BIT + | ColumnType::MYSQL_TYPE_GEOMETRY => ColKind::Binary, + ColumnType::MYSQL_TYPE_VAR_STRING | ColumnType::MYSQL_TYPE_STRING + if col.character_set() == MYSQL_BINARY_CHARSET => + { + ColKind::Binary + } ColumnType::MYSQL_TYPE_DATE | ColumnType::MYSQL_TYPE_NEWDATE => ColKind::Date, ColumnType::MYSQL_TYPE_DATETIME | ColumnType::MYSQL_TYPE_DATETIME2 @@ -78,23 +178,134 @@ impl ColKind { } } +/// Returns a MySQL result column's PDO-visible name, optionally prefixed with +/// the protocol table label for `PDO::ATTR_FETCH_TABLE_NAMES`. +fn column_display_name(column: &Column, fetch_table_names: bool) -> String { + if fetch_table_names { + format!("{}.{}", column.table_str(), column.name_str()) + } else { + column.name_str().into_owned() + } +} + +/// MySQL's own name for a wire column type — the `native_type` key of +/// `PDOStatement::getColumnMeta()` (F-MY-08). +/// +/// The strings are php-src's verbatim, produced by `type_to_name_native` +/// (`ext/pdo_mysql/mysql_statement.c:716-770`), whose +/// `PDO_MYSQL_NATIVE_TYPE_NAME(x)` macro stringifies the `MYSQL_TYPE_` suffix — +/// hence the ones no one would guess from the SQL keyword: an `INT` column is +/// `LONG`, a `TINYINT` is `TINY`, a `BIGINT` is `LONGLONG`, a `MEDIUMINT` is +/// `INT24`, a `VARCHAR` is `VAR_STRING`, a `CHAR` is `STRING`, and a modern +/// `DECIMAL` is `NEWDECIMAL` (`DECIMAL` is only the pre-5.0 legacy type). +/// +/// Returns `""` for a type php-src's switch has no case for (its `default: return +/// NULL`, which makes `pdo_mysql_stmt_col_meta` OMIT the `native_type` key +/// entirely — `mysql_statement.c:812-815`). That covers the `mysql` crate's +/// `MYSQL_TYPE_VARCHAR` (a server-internal type never sent on the wire), +/// `MYSQL_TYPE_TIMESTAMP2`/`DATETIME2`/`TIME2` (likewise internal — the wire +/// carries the plain `TIMESTAMP`/`DATETIME`/`TIME` codes), `MYSQL_TYPE_TYPED_ARRAY` +/// (replication-only) and `MYSQL_TYPE_UNKNOWN`. php-src also names `VECTOR` +/// (MySQL 9), but the `mysql` crate's `ColumnType` has no such variant to match on. +/// The empty string is the bridge's neutral "no metadata" value, so the caller +/// treats those exactly as php-src does: no `native_type` at all. +fn native_type_name(t: ColumnType) -> &'static str { + match t { + ColumnType::MYSQL_TYPE_STRING => "STRING", + ColumnType::MYSQL_TYPE_VAR_STRING => "VAR_STRING", + ColumnType::MYSQL_TYPE_TINY => "TINY", + ColumnType::MYSQL_TYPE_BIT => "BIT", + ColumnType::MYSQL_TYPE_SHORT => "SHORT", + ColumnType::MYSQL_TYPE_LONG => "LONG", + ColumnType::MYSQL_TYPE_LONGLONG => "LONGLONG", + ColumnType::MYSQL_TYPE_INT24 => "INT24", + ColumnType::MYSQL_TYPE_FLOAT => "FLOAT", + ColumnType::MYSQL_TYPE_DOUBLE => "DOUBLE", + ColumnType::MYSQL_TYPE_DECIMAL => "DECIMAL", + ColumnType::MYSQL_TYPE_NEWDECIMAL => "NEWDECIMAL", + ColumnType::MYSQL_TYPE_GEOMETRY => "GEOMETRY", + ColumnType::MYSQL_TYPE_TIMESTAMP => "TIMESTAMP", + ColumnType::MYSQL_TYPE_YEAR => "YEAR", + ColumnType::MYSQL_TYPE_SET => "SET", + ColumnType::MYSQL_TYPE_ENUM => "ENUM", + ColumnType::MYSQL_TYPE_DATE => "DATE", + ColumnType::MYSQL_TYPE_NEWDATE => "NEWDATE", + ColumnType::MYSQL_TYPE_JSON => "JSON", + ColumnType::MYSQL_TYPE_TIME => "TIME", + ColumnType::MYSQL_TYPE_DATETIME => "DATETIME", + ColumnType::MYSQL_TYPE_TINY_BLOB => "TINY_BLOB", + ColumnType::MYSQL_TYPE_MEDIUM_BLOB => "MEDIUM_BLOB", + ColumnType::MYSQL_TYPE_LONG_BLOB => "LONG_BLOB", + ColumnType::MYSQL_TYPE_BLOB => "BLOB", + ColumnType::MYSQL_TYPE_NULL => "NULL", + // php-src's `default: return NULL` — see the doc comment. A wildcard (not + // the remaining variants spelled out) so a `mysql` crate bump that adds a + // wire type keeps compiling into this same php-src-faithful "omit the key". + _ => "", + } +} + /// A live MySQL/MariaDB connection plus the last operation's bookkeeping that PDO /// reads back (`rowCount`, `lastInsertId`, `errorCode`/`errorInfo`). pub struct MyConn { - pub conn: Conn, + conn: MyClientSlot, + /// Transport description returned by `PDO::ATTR_CONNECTION_STATUS`, captured + /// from the resolved connection options in php-src's `mysql_get_host_info()` + /// shape (`"host via TCP/IP"` or `"Localhost via UNIX socket"`). + pub host_info: String, pub changes: i64, pub errmsg: String, pub errcode: i64, + /// 5-char SQLSTATE for the connection's last operation, taken from the ERR + /// packet's SQLSTATE marker (`mysql::error::MySqlError::state`, which the + /// client already parses from the wire protocol's `#`-prefixed field). + /// "00000" on success; "HY000" for a transport/protocol error that carries no + /// SQL error (not a `MySqlError`). + pub sqlstate: String, /// The most recent non-zero AUTO_INCREMENT id, kept sticky across later /// non-INSERT statements (which would otherwise reset the protocol field) to - /// match `PDO::lastInsertId()`. - pub last_id: i64, + /// match `PDO::lastInsertId()`. Stored as `u64` (P2-2's sibling gap): a + /// `BIGINT UNSIGNED` AUTO_INCREMENT id can exceed `i64::MAX`, and casting at + /// storage time would wrap it negative before either accessor ever runs. + pub last_id: u64, + /// Current session autocommit mode, kept in sync with `SET autocommit` for + /// `PDO::ATTR_AUTOCOMMIT` reads and idempotent writes. + pub autocommit: bool, + /// Whether result column names are prefixed with their MySQL table name. + pub fetch_table_names: bool, + /// Default result buffering mode snapshotted by newly prepared statements. + pub buffered_query: bool, + /// Whether client-side execution accepts more than one SQL statement. + pub multi_statements: bool, + /// Whether an unbuffered statement still has unread rows. + pub unbuffered_active: bool, + /// Warning count from the final OK/EOF packet of the last completed operation. + pub warning_count: u16, + /// Best available live transaction state, updated after every successful + /// bridge-owned command including raw `PDO::exec("BEGIN")` control SQL. + pub in_transaction: bool, + /// Handshake version cached while an unbuffered worker temporarily owns `Conn`. + server_version: (u16, u16, u16), + /// Session quoting mode cached before the client moves into a worker. + no_backslash_escapes: bool, + /// Worker currently owning the client for a demand-driven result stream. + active_stream: Option, + /// Monotonic identity used to reject stale statement stream handles. + next_stream_id: u64, } -/// A live MySQL prepared statement and its lazily-materialized result. +/// A live MySQL prepared statement and its buffered or demand-driven result. pub struct MyStmt { pub conn_id: i64, - statement: Statement, + /// Original SQL used for transaction-state bookkeeping and diagnostics. + query_string: String, + statement: Option, + /// Placeholder-translated SQL retained for the text-protocol emulated path. + emulated_sql: Option, + /// Session quoting mode captured when the emulated statement is created. + no_backslash_escape: bool, + /// Most recent client-rendered SQL, exposed by `debugDumpParams()`. + pub sent_sql: String, /// Maps a bare named placeholder (`name` from `:name`) to its 1-based slot. named_map: HashMap, /// For each `?` in source order, the 1-based bound slot that feeds it. Repeats @@ -102,16 +313,139 @@ pub struct MyStmt { order: Vec, /// Bound values, indexed by 0-based slot (`slot 1` → index 0). binds: Vec, + /// Whether each slot was explicitly supplied for the current execution. + bound: Vec, /// Result column names, available from the prepare (before execution). col_names: Vec, /// Result column kinds, parallel to `col_names`, for temporal text rendering. col_kinds: Vec, - /// Materialized rows; each row is a vector of decoded column cells. + /// Raw MySQL wire types, parallel to `col_names` (F-MY-08). Kept alongside the + /// coarser `col_kinds` because `getColumnMeta`'s `native_type` reports the + /// server's OWN type name (`VAR_STRING`, `NEWDECIMAL`, `LONGLONG`, …), a + /// distinction `ColKind` deliberately collapses — every non-temporal, + /// non-binary type lands in `ColKind::Other`. Refreshed from the live result + /// on execute, like `col_names`/`col_kinds`, so a `CALL`'s columns (unknown at + /// prepare time — see `execute`) get real names rather than none. + col_types: Vec, + /// Native table label parallel to `col_names` for `getColumnMeta()`. + col_tables: Vec, + /// Raw MySQL field flags parallel to `col_names`. + col_flags: Vec, + /// Declared maximum byte lengths parallel to `col_names`. + col_lengths: Vec, + /// Native decimal precision markers parallel to `col_names`. + col_precisions: Vec, + /// Buffered rows, or the single active row for an unbuffered stream. rows: Vec>, + /// Result sets after the active one, retained in wire order for + /// `PDOStatement::nextRowset()`. + remaining_rowsets: Vec, /// Current 0-based row index; `-1` before the first `step()`. cursor: isize, - /// Whether the query has been executed (results materialized) yet. + /// Whether the query has been executed yet. executed: bool, + /// Whether the SQL text is a `CALL (...)` invocation (P0-C). A + /// stored procedure's real result shape (whether it `SELECT`s any rows at + /// all, and how many columns) is only known once it actually runs — + /// MySQL's `COM_STMT_PREPARE` always reports zero columns for a `CALL`, + /// unlike a plain `SELECT`, whose column list is already known at prepare + /// time. `column_count()` uses this flag to report a non-zero placeholder + /// before execution instead of that genuine (but misleading) zero — see + /// `column_count`'s doc comment for why that matters. + is_call: bool, + /// Snapshot of the connection's `ATTR_USE_BUFFERED_QUERY` mode. + pub buffered: bool, + /// Connection-owned demand stream used when buffering is disabled. + stream_id: Option, +} + +impl Drop for MyConn { + /// Stops any active row worker before the owning PDO connection is released. + fn drop(&mut self) { + self.finish_active_stream(); + } +} + +/// Keeps the MySQL client optional while an unbuffered worker owns it. +struct MyClientSlot(Option); + +impl Deref for MyClientSlot { + type Target = Conn; + + /// Borrows the connected client outside a demand-driven result stream. + fn deref(&self) -> &Self::Target { + self.0 + .as_ref() + .expect("MySQL client is owned by an active unbuffered stream") + } +} + +impl DerefMut for MyClientSlot { + /// Mutably borrows the connected client outside a demand-driven result stream. + fn deref_mut(&mut self) -> &mut Self::Target { + self.0 + .as_mut() + .expect("MySQL client is owned by an active unbuffered stream") + } +} + +/// Commands sent to the MySQL worker. +enum MyStreamCommand { + Next, + Close, +} + +/// Responses emitted by a MySQL row-stream worker. +enum MyStreamResponse { + Rowset(MyRowset), + Row(Vec), + RowsetEnd, + Finished(Conn, u16), + Failed(Conn, String, i64, String), +} + +/// Connection-owned control plane for one active MySQL result stream. +struct MyActiveStream { + id: u64, + commands: mpsc::Sender, + responses: mpsc::Receiver, + worker: Option>, +} + +/// One fully materialized MySQL protocol result set. +struct MyRowset { + /// Server-reported affected rows for an OK-packet result. + affected: i64, + /// AUTO_INCREMENT id reported by this result set, when present. + last_id: Option, + /// Column names for row-returning result sets. + col_names: Vec, + /// Decoding kinds parallel to `col_names`. + col_kinds: Vec, + /// Raw wire types parallel to `col_names`. + col_types: Vec, + /// Native table labels parallel to `col_names`. + col_tables: Vec, + /// Raw field flags parallel to `col_names`. + col_flags: Vec, + /// Declared maximum byte lengths parallel to `col_names`. + col_lengths: Vec, + /// Native decimal precision markers parallel to `col_names`. + col_precisions: Vec, + /// Decoded rows for this result set. + rows: Vec>, +} + +impl MyRowset { + /// Returns PDO's row count for this result set: buffered row count for a + /// SELECT-like set, otherwise the server's affected-row count. + fn row_count(&self) -> i64 { + if self.col_names.is_empty() { + self.affected + } else { + self.rows.len() as i64 + } + } } /// Extracts a MySQL server error code from a driver error, or `1` for transport / @@ -123,12 +457,56 @@ fn err_code(e: &mysql::Error) -> i64 { } } +/// Extracts the 5-char SQLSTATE from a driver error. The `mysql` crate already +/// parses the ERR packet's SQLSTATE marker (the `#` byte followed by 5 chars) +/// into `MySqlError::state`, so no manual wire-protocol parsing is needed here. +/// Falls back to the generic `HY000` for transport/protocol errors that carry no +/// SQL error (not a `MySqlError`). +fn err_sqlstate(e: &mysql::Error) -> String { + match e { + mysql::Error::MySqlError(me) => me.state.clone(), + _ => "HY000".to_string(), + } +} + /// Parses a PDO `mysql:` DSN (semicolon-separated `key=value` pairs) into the -/// `mysql` client's connection options. Recognises `host`, `port`, `dbname`, -/// `unix_socket`, and the credential keys the prelude folds in (`user`, -/// `password`); unknown keys (e.g. `charset`) are accepted and ignored. Returns an -/// error for a DSN without the `mysql:` prefix. -pub fn build_opts(dsn: &str) -> Result { +/// `mysql` client's connection options, plus a validated `charset` value (P2-3, +/// second tuple element) for the caller to apply. Recognises `host`, `port`, +/// `dbname`, `unix_socket`, the credential keys the prelude folds in (`user`, +/// `password`), `connect_timeout` (P2-1: seconds, mapped to `tcp_connect_timeout` +/// — backs `PDO::ATTR_TIMEOUT`, which the prelude folds into the DSN alongside +/// the credentials since the option needs to take effect before the socket +/// connects), and `charset`; other unknown keys are accepted and ignored. +/// Returns an error for a DSN without the `mysql:` prefix. +/// +/// `charset` has no direct `OptsBuilder` knob in the `mysql` crate, so it is +/// returned as data rather than applied here — `MyConn::open` turns it into a +/// `SET NAMES ` statement alongside `ATTR_INIT_COMMAND` (P1-9) via +/// `OptsBuilder::init`. It is validated here to only contain the identifier +/// characters a real MySQL charset name uses (`[A-Za-z0-9_]`), so a stray value +/// cannot inject SQL into that generated statement; an invalid value is silently +/// dropped (documented best-effort, matching the surrounding DSN parsing style). +/// +/// The connect timeout is ALWAYS applied (F-CORE-10), defaulting to +/// [`DEFAULT_CONNECT_TIMEOUT_SECS`] (30 s) for php-src parity — pdo_mysql's +/// `mysql_options(MYSQL_OPT_CONNECT_TIMEOUT, …)` is unconditional, with +/// `PDO::ATTR_TIMEOUT` only *overriding* the 30 s value rather than lifting the +/// bound. A `connect_timeout=` DSN key (the seam the prelude folds `ATTR_TIMEOUT` +/// into) therefore wins over the default whenever it parses. Deliberate divergence +/// from php-src: the bound is only enforced on the TCP path, since the `mysql` +/// crate consults `tcp_connect_timeout` in `connect_tcp` alone and a `unix_socket` +/// DSN connects locally (where a black-holed peer — the hang this guards against — +/// cannot arise). +/// +/// `found_rows` is the connection's `Pdo\Mysql::ATTR_FOUND_ROWS` constructor +/// option (F-MY-06), threaded in from the open entrypoint rather than read from +/// the DSN: it is an attribute, not a DSN key, and it has to be known *before* +/// the handshake because it only exists as a capability bit negotiated there. +pub fn build_opts( + dsn: &str, + found_rows: bool, + ignore_space: bool, +) -> Result<(OptsBuilder, Option), String> { let body = dsn .strip_prefix("mysql:") .ok_or_else(|| "could not find driver (expected a mysql: DSN)".to_string())?; @@ -138,6 +516,8 @@ pub fn build_opts(dsn: &str) -> Result { let mut socket: Option = None; let mut user: Option = None; let mut password: Option = None; + let mut connect_timeout: Option = None; + let mut charset: Option = None; for pair in body.split(';') { let pair = pair.trim(); if pair.is_empty() { @@ -152,85 +532,635 @@ pub fn build_opts(dsn: &str) -> Result { "port" => port = value.parse::().ok(), "dbname" => dbname = Some(value), "unix_socket" | "socket" => socket = Some(value), - "user" => user = Some(value), - "password" => password = Some(value), - // charset and any other key are accepted for DSN compatibility but - // have no direct option here (modern MariaDB defaults to utf8mb4). + "user" => user = Some(percent_decode_credential(&value)), + "password" => password = Some(percent_decode_credential(&value)), + "connect_timeout" => connect_timeout = value.parse::().ok(), + "charset" => { + if !value.is_empty() + && value.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + charset = Some(value); + } + } + // any other key is accepted for DSN compatibility but has no direct + // option here. _ => {} } } let mut opts = OptsBuilder::new().user(user).pass(password).db_name(dbname); - // A unix socket DSN connects locally; otherwise connect over TCP (defaulting - // the host so a `mysql:dbname=…` DSN still reaches a local server). - if let Some(sock) = socket { - opts = opts.socket(Some(sock)); + // F-MY-02: `unix_socket` only wins when the DSN names no host, or names exactly + // `localhost`. php-src's handle factory takes the socket under precisely that + // condition — `if (vars[0].optval && !strcmp("localhost", vars[0].optval))` + // (`mysql_driver.c:940-946`), with the DSN parser defaulting an absent `host` to + // `"localhost"` — so `mysql:host=127.0.0.1;unix_socket=/tmp/mysql.sock` is + // TCP-only in real PHP, the socket key silently ignored. Preferring the socket + // whenever it was present (as this did) connected such a DSN to a DIFFERENT + // server than php-src would. The comparison is case-sensitive, matching the + // `strcmp`; `127.0.0.1` is deliberately NOT localhost here, exactly as in + // php-src (and in the mysql client itself, where the two are distinct). + // Otherwise connect over TCP, defaulting the host so a `mysql:dbname=…` DSN + // still reaches a local server. + let host_is_localhost = host.as_deref().is_none_or(|h| h == "localhost"); + match socket.filter(|_| host_is_localhost) { + Some(sock) => opts = opts.socket(Some(sock)), + None => { + opts = opts.ip_or_hostname(Some(host.unwrap_or_else(|| "localhost".to_string()))); + if let Some(p) = port { + opts = opts.tcp_port(p); + } + } + } + // F-MY-06: `Pdo\Mysql::ATTR_FOUND_ROWS` ORs `CLIENT_FOUND_ROWS` into the connect + // capabilities (php-src `mysql_driver.c:776-778`), which switches what the server + // reports as the affected-row count of an UPDATE — and so `PDOStatement:: + // rowCount()` — from "rows actually CHANGED" to "rows MATCHED by the WHERE + // clause". Without it there was no way to opt into the matched-rows semantics + // apps commonly rely on. The `mysql` crate ORs `additional_capabilities` into the + // handshake's client flags (`conn/mod.rs:739`), and its forbidden-flag filter + // covers only the capabilities the connection manages itself (`CLIENT_SSL`, + // `CLIENT_COMPRESS`, `CLIENT_PROTOCOL_41`, the MULTI_* pair, …) — never + // `CLIENT_FOUND_ROWS` — so the bit does reach the server. + let mut capabilities = CapabilityFlags::empty(); + if found_rows { + capabilities.insert(CapabilityFlags::CLIENT_FOUND_ROWS); + } + if ignore_space { + capabilities.insert(CapabilityFlags::CLIENT_IGNORE_SPACE); + } + if !capabilities.is_empty() { + opts = opts.additional_capabilities(capabilities); + } + // F-CORE-10: unconditional, so a DSN that names neither `connect_timeout` nor + // (through the prelude) `ATTR_TIMEOUT` still inherits php-src's 30 s bound + // instead of waiting out the OS TCP timeout. An explicit value — from either + // seam, both of which land in `connect_timeout` above — wins over the default. + let secs = connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT_SECS); + opts = opts.tcp_connect_timeout(Some(Duration::from_secs(secs))); + Ok((opts, charset)) +} + +/// Parses the percent-escaped MySQL driver-option string emitted by the PDO +/// prelude. Unsupported security options fail the connection explicitly instead +/// of being accepted into an inert attribute bag. +fn parse_driver_options(config: &str) -> Result { + let mut options = MyDriverOptions::default(); + for pair in config.split(';').filter(|pair| !pair.is_empty()) { + let Some((key, raw_value)) = pair.split_once('=') else { + continue; + }; + let value = percent_decode_credential(raw_value); + match key { + "local" => options.local_infile = value == "1", + "dir" if !value.is_empty() => { + options.local_infile_directory = Some(PathBuf::from(value)) + } + "compress" => options.compress = value == "1", + "ignore" => options.ignore_space = value == "1", + "multi" => options.multi_statements = value != "0", + "buffered" => options.buffered_query = value != "0", + "capath" if !value.is_empty() => options.ssl_ca_path = Some(PathBuf::from(value)), + "cipher" if !value.is_empty() => options.ssl_cipher = Some(value), + "serverkey" if !value.is_empty() => { + options.server_public_key = Some(PathBuf::from(value)) + } + _ => {} + } + } + if let Some(directory) = options.local_infile_directory.as_mut() { + *directory = directory.canonicalize().map_err(|error| { + format!( + "Pdo\\Mysql::ATTR_LOCAL_INFILE_DIRECTORY '{}': {error}", + directory.display() + ) + })?; + if !directory.is_dir() { + return Err(format!( + "Pdo\\Mysql::ATTR_LOCAL_INFILE_DIRECTORY '{}' is not a directory", + directory.display() + )); + } + } + if let Some(public_key) = options.server_public_key.as_mut() { + *public_key = public_key.canonicalize().map_err(|error| { + format!( + "Pdo\\Mysql::ATTR_SERVER_PUBLIC_KEY '{}': {error}", + public_key.display() + ) + })?; + if !public_key.is_file() { + return Err(format!( + "Pdo\\Mysql::ATTR_SERVER_PUBLIC_KEY '{}' is not a file", + public_key.display() + )); + } + } + Ok(options) +} + +/// Builds the local-infile callback installed on every MySQL connection. Disabled +/// connections always reject the server request. Enabled connections read the +/// requested file bytes, optionally requiring the canonical path to remain below +/// `allowed_directory`, and never acknowledge an empty synthetic upload on error. +fn local_infile_handler( + enabled: bool, + allowed_directory: Option, +) -> LocalInfileHandler { + LocalInfileHandler::new(move |file_name, writer| { + if !enabled { + return Err(IoError::new( + ErrorKind::PermissionDenied, + "LOAD DATA LOCAL INFILE is disabled", + )); + } + let requested = String::from_utf8_lossy(file_name); + let path = PathBuf::from(requested.as_ref()); + let absolute = if path.is_absolute() { + path + } else { + std::env::current_dir()?.join(path) + }; + let canonical = absolute.canonicalize()?; + if let Some(root) = &allowed_directory { + if !canonical.starts_with(root) { + return Err(IoError::new( + ErrorKind::PermissionDenied, + format!( + "LOCAL INFILE path '{}' is outside allowed directory '{}'", + canonical.display(), + root.display() + ), + )); + } + } + writer.write_all(&std::fs::read(canonical)?) + }) +} + +/// Percent-decodes a `user=`/`password=` DSN value (F-CORE-02). The prelude +/// percent-encodes '%' and ';' on the credential it folds into the DSN — '%' +/// first, so the '%' introduced by encoding ';' as `%3B` is not itself +/// re-encoded — precisely so a ';' or '%' embedded in the username/password +/// survives `body.split(';')` above instead of truncating the credential. +/// This undoes that encoding; a value with no '%' is returned unchanged +/// (byte-identical) without allocating a new string. An invalid or truncated +/// escape (not two hex digits) is copied through verbatim rather than +/// rejected, since a bare '%' is legal in a value that predates this scheme. +fn percent_decode_credential(raw: &str) -> String { + if !raw.contains('%') { + return raw.to_string(); + } + let b = raw.as_bytes(); + let mut out: Vec = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' + && i + 2 < b.len() + && b[i + 1].is_ascii_hexdigit() + && b[i + 2].is_ascii_hexdigit() + { + let hi = (b[i + 1] as char).to_digit(16).unwrap() as u8; + let lo = (b[i + 2] as char).to_digit(16).unwrap() as u8; + out.push((hi << 4) | lo); + i += 3; + } else { + out.push(b[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Returns whether `b` is an identifier byte (`[A-Za-z0-9_]`), used to read a +/// `:name` placeholder's name. +fn is_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +/// Returns the byte length of the UTF-8 sequence led by `b` (1 for ASCII, 2-4 +/// for a multi-byte lead byte). `sql` is always valid UTF-8, so slicing +/// `&sql[i..i + utf8_len(bytes[i])]` lands on a valid char boundary at both +/// ends — used to copy a content byte (or run of one multi-byte codepoint) +/// through `out.push_str` instead of `out.push(b as char)`, which corrupts any +/// codepoint above U+007F: a `u8` cast to `char` treats each raw continuation +/// byte as its own Latin-1 codepoint and re-encodes it as 2 UTF-8 bytes, +/// doubling/mangling every multi-byte character embedded in the SQL text +/// (BUG 1). +fn utf8_len(b: u8) -> usize { + if b & 0x80 == 0 { + 1 + } else if b & 0xE0 == 0xC0 { + 2 + } else if b & 0xF0 == 0xE0 { + 3 + } else if b & 0xF8 == 0xF0 { + 4 } else { - opts = opts.ip_or_hostname(Some(host.unwrap_or_else(|| "localhost".to_string()))); - if let Some(p) = port { - opts = opts.tcp_port(p); + // A stray continuation byte can't start a codepoint in valid UTF-8; + // fall back to one byte so the scanner still makes forward progress. + 1 + } +} + +/// Returns whether `bytes[i]` opens a MySQL comment and, if so, its exclusive end +/// index. The single definition of MySQL's three comment forms, shared by +/// `translate_placeholders` (which copies the region verbatim, never scanning it +/// for placeholders) and `sql_is_call_statement` (which skips it) so the two can +/// never drift apart: +/// - `--` line comment, but only when the second dash is followed by one of the +/// whitespace/control bytes `[ \t\v\f\r]` — the php-src `mysql_sql_parser.re` +/// COMMENTS rule. Without that trailing byte `a--b` is the arithmetic `a - -b` +/// (unlike PostgreSQL, where a bare `--` already comments); +/// - `#` line comment, with no trailing-whitespace requirement; +/// - `/* ... */` block comment, non-nested, running to EOF if unterminated. +/// +/// A line comment ends *at* the newline (exclusive), which the caller then treats +/// as ordinary text/whitespace. An out-of-range `i` opens nothing (`None`), so a +/// caller scanning to EOF needs no separate bounds check. +fn scan_my_comment(bytes: &[u8], i: usize) -> Option { + let len = bytes.len(); + match *bytes.get(i)? { + b'-' if i + 2 < len + && bytes[i + 1] == b'-' + && matches!(bytes[i + 2], b' ' | b'\t' | b'\x0b' | b'\x0c' | b'\r') => + { + let mut j = i + 2; + while j < len && bytes[j] != b'\n' { + j += 1; + } + Some(j) + } + b'#' => { + let mut j = i + 1; + while j < len && bytes[j] != b'\n' { + j += 1; + } + Some(j) + } + b'/' if i + 1 < len && bytes[i + 1] == b'*' => { + let mut j = i + 2; + while j + 1 < len && !(bytes[j] == b'*' && bytes[j + 1] == b'/') { + j += 1; + } + Some(if j + 1 < len { j + 2 } else { len }) + } + _ => None, + } +} + +/// Returns the end of a comment recognized by php-src's generic PDO scanner. +/// +/// Unlike the MySQL-specific scanner, the generic scanner accepts every `--` +/// line comment and does not treat `#` as a comment opener. That distinction is +/// required for SQL Server temporary-table identifiers such as `#events`. +fn scan_pdo_comment(bytes: &[u8], i: usize) -> Option { + let len = bytes.len(); + match *bytes.get(i)? { + b'-' if i + 1 < len && bytes[i + 1] == b'-' => { + let mut j = i + 2; + while j < len && bytes[j] != b'\n' { + j += 1; + } + Some(j) + } + b'/' if i + 1 < len && bytes[i + 1] == b'*' => { + let mut j = i + 2; + while j + 1 < len && !(bytes[j] == b'*' && bytes[j + 1] == b'/') { + j += 1; + } + Some(if j + 1 < len { j + 2 } else { len }) + } + _ => None, + } +} + +/// Returns whether `b` is whitespace to MySQL's lexer (`[ \t\n\v\f\r]`, the +/// `_MY_SPC` ctype class). Deliberately narrower than `str::trim_start`, which +/// also strips Unicode spaces (e.g. NBSP) that the server would reject as a +/// syntax error rather than skip. +fn is_my_space(b: u8) -> bool { + matches!(b, b' ' | b'\t' | b'\n' | b'\x0b' | b'\x0c' | b'\r') +} + +/// Returns whether `sql` invokes a stored procedure (`CALL proc(...)`, +/// case-insensitive), ignoring any leading whitespace *and comments*. Used by +/// `prepare()` to set `MyStmt::is_call` — see `column_count`'s doc comment for why +/// a `CALL`'s prepare-time column count needs this special-casing. Requires a +/// non-identifier byte (or end of string) right after the keyword, so +/// `CALLBACK(...)` — a (nonsensical but not a stored-procedure-call) function-call +/// expression — is never mistaken for `CALL BACK(...)`. +/// +/// The comment skipping is load-bearing, not cosmetic: the server ignores whatever +/// leads the statement, so `/* hint */ CALL p()` and `-- note\nCALL p()` are just as +/// much stored-procedure calls as a bare `CALL p()`. Testing only past the +/// whitespace left those mis-flagged as non-`CALL`, which handed the prelude the +/// genuine (but meaningless) prepare-time column count of `0` and so routed a +/// row-producing procedure into the no-result DML branch — silently discarding its +/// first row (the row-dropping bug `column_count` documents). +fn sql_is_call_statement(sql: &str) -> bool { + let bytes = sql.as_bytes(); + let len = bytes.len(); + let mut i = 0; + // Whitespace and the three comment forms can interleave in any order and + // quantity ahead of the first keyword, so alternate between them until neither + // consumes anything (a line comment stops at its newline, which the whitespace + // pass then eats, so the loop always makes progress). + loop { + let start = i; + while i < len && is_my_space(bytes[i]) { + i += 1; + } + if let Some(end) = scan_my_comment(bytes, i) { + i = end; + } + if i == start { + break; + } + } + let rest = &bytes[i..]; + if rest.len() < 4 || !rest[..4].eq_ignore_ascii_case(b"call") { + return false; + } + rest.get(4).is_none_or(|&b| !is_ident_byte(b)) +} + +/// Returns whether `sql` contains a second non-empty statement after a real +/// semicolon separator. Quoted regions and MySQL comments are skipped with the +/// same escape rules as placeholder translation, so a semicolon inside data +/// never trips `ATTR_MULTI_STATEMENTS = false`. +fn sql_has_multiple_statements(sql: &str, no_backslash_escapes: bool) -> bool { + let bytes = sql.as_bytes(); + let mut i = 0usize; + let mut saw_statement = false; + let mut completed_statement = false; + while i < bytes.len() { + if is_my_space(bytes[i]) { + i += 1; + continue; } + if let Some(end) = scan_my_comment(bytes, i) { + i = end; + continue; + } + if bytes[i] == b';' { + if saw_statement { + completed_statement = true; + saw_statement = false; + } + i += 1; + continue; + } + if completed_statement { + return true; + } + saw_statement = true; + if matches!(bytes[i], b'\'' | b'"') { + i = scan_my_string(bytes, i, bytes[i], no_backslash_escapes) + .unwrap_or(bytes.len()); + continue; + } + if bytes[i] == b'`' { + i += 1; + while i < bytes.len() { + if bytes[i] == b'`' { + if i + 1 < bytes.len() && bytes[i + 1] == b'`' { + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + continue; + } + i += 1; + } + false +} + +/// Scans a MySQL quoted region opened by `quote` (`'` or `"`) starting at +/// `start` (the index of the opening quote byte), returning the exclusive end +/// index just past the closing quote, or `None` when it is unterminated. Both quote +/// styles are string literals in MySQL's default `sql_mode` and share the same +/// escaping: a doubled quote (`''`/`""`) is a literal quote, and a backslash +/// escapes the following byte unconditionally (so `\'`/`\"`/`\\` never +/// terminate or mis-parse the string). +/// +/// F-MY-03: under the `NO_BACKSLASH_ESCAPES` `sql_mode` (`no_backslash_escapes` +/// true) the SERVER treats `\` as an ordinary byte inside a string literal — +/// doubling is then the ONLY escape. This is the same server-side fact +/// `PDO::quote()`'s MySQL branch already keys off (it falls back to `''`-doubling +/// there), and the scanner has to agree with it: assuming backslash-escaping in +/// that mode makes the scanner disagree with the server about where a literal +/// ENDS, so a `?`/`:name` the server sees as a real placeholder can be swallowed +/// as string content (e.g. `'a\' , ?` — the server closes the literal at the `'` +/// after the backslash, this scanner would not), yielding a bound-parameter count +/// that disagrees with the server's real placeholder count. +fn scan_my_string( + bytes: &[u8], + start: usize, + quote: u8, + no_backslash_escapes: bool, +) -> Option { + let len = bytes.len(); + let mut j = start + 1; + loop { + if j >= len { + return None; + } + let cj = bytes[j]; + if !no_backslash_escapes && cj == b'\\' && j + 1 < len { + j += 2; + continue; + } + if cj == quote { + if j + 1 < len && bytes[j + 1] == quote { + j += 2; + continue; + } + return Some(j + 1); + } + j += 1; } - Ok(opts) } /// Translates PDO `?` and `:name` placeholders to MySQL's positional `?`, -/// returning the rewritten SQL, the bare-name → 1-based-slot map, and a per-`?` -/// `order` (the slot each emitted `?` reads). Single-quoted string literals are -/// passed through untouched, and a `::` sequence is not mistaken for a named -/// placeholder. -pub fn translate_placeholders(sql: &str) -> (String, HashMap, Vec) { +/// returning the rewritten SQL, the bare-name → 1-based-slot map, a per-`?` +/// `order` (the slot each emitted `?` reads), and whether the SQL mixed a +/// positional `?` with a named `:name` (PDO forbids this combination; +/// `prepare()` checks the flag and raises `HY093` before ever reaching the +/// server). +/// +/// The scanner tracks these mutually exclusive regions, copying each verbatim +/// (never scanning `?`/`:name` inside them) before resuming normal placeholder +/// scanning: +/// - `-- ...` and `# ...` line comments (to end of line or EOF); +/// - `/* ... */` block comments (non-nested, to the first `*/` or EOF); +/// - `'...'` and `"..."` string literals — both quote styles honor the doubled- +/// quote escape (`''`/`""`) and, unless `no_backslash_escapes` is set, backslash +/// escapes (`\'`, `\"`, `\\`, …), per MySQL's default `sql_mode`; +/// - `` `...` `` backtick-quoted identifiers, with `` `` `` as the doubled-quote +/// escape (no backslash escaping here). +/// +/// `no_backslash_escapes` is the connection's LIVE `NO_BACKSLASH_ESCAPES` +/// `sql_mode` state (F-MY-03), threaded in from `MyConn::prepare` — see +/// [`scan_my_string`] for why a scanner that disagrees with the server about +/// backslash escaping also disagrees with it about the placeholder count. It only +/// affects the two string-literal forms: a backtick-quoted identifier never +/// honors backslash escapes in either mode, and a comment has no escapes at all. +/// +/// A run of two or more `?` (e.g. `??`) is a single verbatim text token (php-src +/// treats it the same way) and allocates no slot; only a lone `?` is a real +/// positional placeholder. Symmetrically, a run of two or more `:` is left +/// untouched rather than read as a named placeholder. +/// +/// A `:name` immediately preceded by an alphanumeric byte is NOT a named +/// placeholder (matching php-src's `pdo_sql_parser.re`, which skips the same +/// way). +pub fn translate_placeholders( + sql: &str, + no_backslash_escapes: bool, +) -> (String, HashMap, Vec, bool) { + translate_placeholders_impl(sql, no_backslash_escapes, true) +} + +/// Translates placeholders with php-src's generic PDO scanner rules. +/// +/// PDO_DBLIB, PDO_FIREBIRD, PDO_ODBC, PDO_INFORMIX, PDO_IBM, and PDO_SQLSRV use +/// the shared scanner, where `#` is ordinary SQL text and backslashes do not +/// escape quote delimiters. +#[cfg(any( + test, + feature = "dblib", + feature = "firebird", + feature = "odbc", + feature = "informix", + feature = "ibm", + feature = "sqlsrv" +))] +pub(crate) fn translate_pdo_placeholders( + sql: &str, +) -> (String, HashMap, Vec, bool) { + translate_placeholders_impl(sql, true, false) +} + +/// Implements placeholder translation for either the MySQL or generic PDO dialect. +fn translate_placeholders_impl( + sql: &str, + no_backslash_escapes: bool, + mysql_rules: bool, +) -> (String, HashMap, Vec, bool) { let bytes = sql.as_bytes(); + let len = bytes.len(); let mut out = String::with_capacity(sql.len() + 8); let mut named: HashMap = HashMap::new(); let mut order: Vec = Vec::new(); let mut next_slot: i64 = 1; let mut i = 0; - let mut in_string = false; - while i < bytes.len() { - let c = bytes[i] as char; - if in_string { - out.push(c); - if c == '\'' { - // Doubled '' is an escaped quote inside the literal. - if i + 1 < bytes.len() && bytes[i + 1] == b'\'' { - out.push('\''); - i += 2; - continue; - } - in_string = false; + let mut saw_positional = false; + let mut saw_named = false; + while i < len { + let c = bytes[i]; + // Copy the active dialect's comments verbatim so markers inside them are + // never mistaken for placeholders. + let comment_end = if mysql_rules { + scan_my_comment(bytes, i) + } else { + scan_pdo_comment(bytes, i) + }; + if let Some(end) = comment_end { + if c == b'/' + && i + 1 < len + && bytes[i + 1] == b'*' + && end == len + && (len < 2 || &bytes[len - 2..] != b"*/") + { + // php-src's re2c scanner backtracks an unterminated block comment + // to its one-byte fallback instead of swallowing the rest of the + // statement. Copy only '/' so '*' and later placeholders are scanned. + out.push('/'); + i += 1; + continue; } - i += 1; + out.push_str(&sql[i..end]); + i = end; continue; } match c { - '\'' => { - in_string = true; - out.push(c); - i += 1; + b'\'' | b'"' => { + if let Some(end) = + scan_my_string(bytes, i, c, no_backslash_escapes || !mysql_rules) + { + out.push_str(&sql[i..end]); + i = end; + } else { + // Match php-src's scanner fallback for an unterminated quote: + // the opener is ordinary text and following placeholders remain visible. + out.push(c as char); + i += 1; + } } - '?' => { - // Each positional placeholder is its own fresh slot. - out.push('?'); - order.push(next_slot); - next_slot += 1; - i += 1; + b'`' if mysql_rules => { + // Backtick-quoted identifier: verbatim, with doubled `` `` `` + // as the escape (no backslash escaping here). + let start = i; + let mut j = i + 1; + loop { + if j >= len { + break; + } + if bytes[j] == b'`' { + if j + 1 < len && bytes[j + 1] == b'`' { + j += 2; + continue; + } + j += 1; + break; + } + j += 1; + } + out.push_str(&sql[start..j]); + i = j; + } + b'?' => { + // A run of 2+ `?` is a single verbatim text token, no slot + // allocated; a lone `?` is a fresh positional placeholder. + let mut j = i + 1; + while j < len && bytes[j] == b'?' { + j += 1; + } + if j - i >= 2 { + out.push_str(&sql[i..j]); + i = j; + } else { + out.push('?'); + order.push(next_slot); + next_slot += 1; + saw_positional = true; + i += 1; + } } - ':' => { - // `::` is not a named placeholder; emit verbatim. - if i + 1 < bytes.len() && bytes[i + 1] == b':' { - out.push_str("::"); - i += 2; + b':' => { + // A run of 2+ `:` is a single verbatim text token, never a named + // placeholder — php-src's `MULTICHAR = [:]{2,}` rule is greedy + // (re2c's maximal munch swallows the whole contiguous run). The run + // must be consumed WHOLE: taking colons two at a time leaves the + // third one of an odd run (`:::c`) to be re-scanned as a fresh + // `:c`, conjuring a named placeholder php-src never emits. Mirrors + // the `?`-run handling above. + let mut run_end = i + 1; + while run_end < len && bytes[run_end] == b':' { + run_end += 1; + } + if run_end - i >= 2 { + out.push_str(&sql[i..run_end]); + i = run_end; continue; } let start = i + 1; let mut j = start; - while j < bytes.len() { - let nc = bytes[j] as char; - if nc.is_ascii_alphanumeric() || nc == '_' { - j += 1; - } else { - break; - } + while j < len && is_ident_byte(bytes[j]) { + j += 1; } if j == start { // A bare colon (not a named placeholder); emit verbatim. @@ -238,6 +1168,16 @@ pub fn translate_placeholders(sql: &str) -> (String, HashMap, Vec 0 && bytes[i - 1].is_ascii_alphanumeric() { + out.push(':'); + i += 1; + continue; + } let name = &sql[start..j]; // Reused names share a slot; first sight allocates the next slot. let slot = *named.entry(name.to_string()).or_insert_with(|| { @@ -247,51 +1187,724 @@ pub fn translate_placeholders(sql: &str) -> (String, HashMap, Vec { - out.push(c); + // Copy the whole codepoint via a slice (BUG 1): `c as char` + // would corrupt any multi-byte UTF-8 character (e.g. an + // embedded `'café'` byte outside a recognized quoted region — + // the ordinary/unquoted path). + let n = utf8_len(c).min(len - i); + out.push_str(&sql[i..i + n]); + i += n; + } + } + } + let mixed = saw_positional && saw_named; + (out, named, order, mixed) +} + +/// Replaces the translated statement's real `?` markers with safely quoted +/// MySQL literals while preserving markers inside comments and quoted regions. +fn interpolate_emulated_sql( + sql: &str, + values: &[Value], + national: &[bool], + no_backslash_escape: bool, +) -> Result { + let bytes = sql.as_bytes(); + let mut out = String::with_capacity(sql.len() + values.len() * 8); + let mut value_index = 0usize; + let mut i = 0usize; + while i < bytes.len() { + if let Some(end) = scan_my_comment(bytes, i) { + if bytes[i] == b'/' + && i + 1 < bytes.len() + && bytes[i + 1] == b'*' + && end == bytes.len() + && (bytes.len() < 2 || &bytes[bytes.len() - 2..] != b"*/") + { + out.push('/'); + i += 1; + continue; + } + out.push_str(&sql[i..end]); + i = end; + continue; + } + match bytes[i] { + quote @ (b'\'' | b'"') => { + if let Some(end) = scan_my_string(bytes, i, quote, no_backslash_escape) { + out.push_str(&sql[i..end]); + i = end; + } else { + out.push(quote as char); + i += 1; + } + } + b'`' => { + let start = i; i += 1; + while i < bytes.len() { + if bytes[i] == b'`' { + i += 1; + if i < bytes.len() && bytes[i] == b'`' { + i += 1; + continue; + } + break; + } + i += 1; + } + out.push_str(&sql[start..i]); } + b'?' => { + let mut end = i + 1; + while end < bytes.len() && bytes[end] == b'?' { + end += 1; + } + if end - i > 1 { + out.push_str(&sql[i..end]); + i = end; + continue; + } + let value = values.get(value_index).ok_or_else(|| { + "Invalid parameter number: number of bound variables does not match number of tokens" + .to_string() + })?; + if national.get(value_index).copied().unwrap_or(false) { + out.push('N'); + } + out.push_str(&value.as_sql(no_backslash_escape)); + value_index += 1; + i += 1; + } + _ => { + let len = utf8_len(bytes[i]).min(bytes.len() - i); + out.push_str(&sql[i..i + len]); + i += len; + } + } + } + if value_index != values.len() { + return Err( + "Invalid parameter number: number of bound variables does not match number of tokens" + .to_string(), + ); + } + Ok(out) +} + +/// Applies the prelude's packed `Pdo\Mysql::ATTR_SSL_*` config to `opts`, enabling +/// rustls TLS for the connection. The default build enables mysql 28's ring-backed +/// `mysql-tls` feature. An empty config leaves `opts` untouched (plaintext). +#[cfg(feature = "mysql-tls")] +fn apply_ssl_opts( + opts: OptsBuilder, + ssl_config: &str, + cipher: Option<&str>, +) -> Result { + if ssl_config.is_empty() && cipher.is_none() { + return Ok(opts); + } + install_crypto_provider(); + Ok(opts.ssl_opts(parse_ssl_config(ssl_config, cipher))) +} + +/// Owns a temporary PEM bundle assembled from MySQL's CA file/directory options. +/// +/// The mysql crate reads the path while `Conn::new` builds its rustls connector, +/// so the bundle only needs to survive that call and is removed automatically +/// afterwards, including on connection failure. +struct TemporaryCaBundle { + path: PathBuf, +} + +impl Drop for TemporaryCaBundle { + /// Removes the private bundle created for one connection attempt. + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +/// Combines `ATTR_SSL_CA` and every PEM certificate in `ATTR_SSL_CAPATH`. +/// +/// libmysqlclient accepts a CA file and an OpenSSL hashed CA directory. Rustls +/// accepts a multi-certificate PEM file instead, so concatenating the directory's +/// certificates preserves the same trust-anchor semantics without weakening +/// verification. Returns the rewritten SSL config plus an owner for the temporary +/// bundle when a directory was requested. +fn normalize_ssl_ca_sources( + ssl_config: &str, + ca_path: Option<&std::path::Path>, +) -> Result<(String, Option), String> { + let Some(ca_path) = ca_path else { + return Ok((ssl_config.to_string(), None)); + }; + let canonical = ca_path.canonicalize().map_err(|error| { + format!( + "Pdo\\Mysql::ATTR_SSL_CAPATH '{}': {error}", + ca_path.display() + ) + })?; + if !canonical.is_dir() { + return Err(format!( + "Pdo\\Mysql::ATTR_SSL_CAPATH '{}' is not a directory", + canonical.display() + )); + } + + let mut pem = Vec::new(); + for pair in ssl_config.split(';').filter(|pair| !pair.is_empty()) { + if let Some(("ca", value)) = pair.split_once('=') { + let bytes = fs::read(value) + .map_err(|error| format!("Pdo\\Mysql::ATTR_SSL_CA '{value}': {error}"))?; + append_pem_certificates(&mut pem, &bytes); + } + } + + let mut entries = fs::read_dir(&canonical) + .map_err(|error| format!("Pdo\\Mysql::ATTR_SSL_CAPATH '{}': {error}", canonical.display()))? + .collect::, _>>() + .map_err(|error| format!("Pdo\\Mysql::ATTR_SSL_CAPATH '{}': {error}", canonical.display()))?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let metadata = entry.metadata().map_err(|error| { + format!( + "Pdo\\Mysql::ATTR_SSL_CAPATH '{}': {error}", + entry.path().display() + ) + })?; + // OpenSSL-style CA directories commonly contain c_rehash symlinks; the + // selected directory is trusted configuration, so follow them to files. + if !metadata.is_file() { + continue; + } + let bytes = fs::read(entry.path()).map_err(|error| { + format!( + "Pdo\\Mysql::ATTR_SSL_CAPATH '{}': {error}", + entry.path().display() + ) + })?; + append_pem_certificates(&mut pem, &bytes); + } + if pem.is_empty() { + return Err(format!( + "Pdo\\Mysql::ATTR_SSL_CAPATH '{}' contains no PEM certificates", + canonical.display() + )); + } + + static NEXT_BUNDLE_ID: AtomicU64 = AtomicU64::new(1); + let id = NEXT_BUNDLE_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "elephc-pdo-mysql-ca-{}-{id}.pem", + std::process::id() + )); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|error| format!("cannot create MySQL CA bundle '{}': {error}", path.display()))?; + file.write_all(&pem) + .map_err(|error| format!("cannot write MySQL CA bundle '{}': {error}", path.display()))?; + + let rewritten = ssl_config + .split(';') + .filter(|pair| !pair.is_empty()) + .filter(|pair| !matches!(pair.split_once('='), Some(("ca", _)))) + .fold(format!("ca={};", path.display()), |mut config, pair| { + config.push_str(pair); + config.push(';'); + config + }); + Ok((rewritten, Some(TemporaryCaBundle { path }))) +} + +/// Appends a PEM source when it contains at least one certificate block. +fn append_pem_certificates(output: &mut Vec, source: &[u8]) { + if !source + .windows(b"-----BEGIN CERTIFICATE-----".len()) + .any(|window| window == b"-----BEGIN CERTIFICATE-----") + { + return; + } + output.extend_from_slice(source); + if !source.ends_with(b"\n") { + output.push(b'\n'); + } +} + +/// A custom build without `mysql-tls` has no MySQL TLS backend linked. Rather than +/// silently downgrade a program that asked for TLS to a plaintext connection, a +/// non-empty SSL config fails loudly; an empty config (no TLS requested) connects +/// normally. +#[cfg(not(feature = "mysql-tls"))] +fn apply_ssl_opts( + opts: OptsBuilder, + ssl_config: &str, + cipher: Option<&str>, +) -> Result { + if ssl_config.is_empty() && cipher.is_none() { + return Ok(opts); + } + Err("mysql TLS (Pdo\\Mysql::ATTR_SSL_*) was requested but requires the \ + `mysql-tls` feature, which was not compiled in (rebuild elephc-pdo with \ + --features mysql-tls)" + .to_string()) +} + +/// Installs the ring `CryptoProvider` as the process default exactly once. The +/// `mysql` crate builds its rustls `ClientConfig` with the provider-less +/// `ClientConfig::builder()`, which panics when more than one provider is present +/// unless a process default is installed. mysql 28's `rustls-tls-ring` feature +/// uses the same provider as pg / elephc-tls; installing it explicitly keeps the +/// choice deterministic when the final binary links other rustls users. +#[cfg(feature = "mysql-tls")] +fn install_crypto_provider() { + use std::sync::Once; + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| { + // Ignored on the (harmless) race where another path already installed one. + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +/// Parses the prelude's packed SSL config (`ca=…;cert=…;key=…;verify=0|1`) into +/// mysql `SslOpts`. `ca` is `MYSQL_ATTR_SSL_CA` (a server CA bundle to trust on +/// top of the bundled webpki roots); `cert`+`key` are `MYSQL_ATTR_SSL_CERT`/ +/// `SSL_KEY` (client-certificate mutual TLS, honored only when both are present); +/// `verify=0` is `MYSQL_ATTR_SSL_VERIFY_SERVER_CERT` set false, which disables +/// certificate and hostname validation via the crate's danger flags. Unsupported +/// security keys never reach this parser: `parse_driver_options` rejects them first. +#[cfg(feature = "mysql-tls")] +fn parse_ssl_config(ssl_config: &str, cipher: Option<&str>) -> mysql::SslOpts { + use mysql::{ClientIdentity, SslOpts}; + use std::path::PathBuf; + + let mut ca: Option = None; + let mut cert: Option = None; + let mut key: Option = None; + let mut verify = true; + for pair in ssl_config.split(';') { + let Some((k, v)) = pair.trim().split_once('=') else { + continue; + }; + let v = v.trim().to_string(); + match k.trim() { + "ca" => ca = Some(v), + "cert" => cert = Some(v), + "key" => key = Some(v), + "verify" => verify = v != "0", + _ => {} } } - (out, named, order) + + let mut ssl = SslOpts::default(); + if let Some(ca) = ca { + ssl = ssl.with_root_cert_path(Some(PathBuf::from(ca))); + } + if let (Some(cert), Some(key)) = (cert, key) { + ssl = ssl.with_client_identity(Some(ClientIdentity::new( + PathBuf::from(cert), + PathBuf::from(key), + ))); + } + if !verify { + ssl = ssl + .with_danger_skip_domain_validation(true) + .with_danger_accept_invalid_certs(true); + } + if let Some(cipher) = cipher { + let suites = cipher + .split([':', ',']) + .map(str::trim) + .filter(|suite| !suite.is_empty()) + .map(str::to_string) + .collect(); + ssl = ssl.with_cipher_suites(Some(suites)); + } + ssl } impl MyConn { - /// Connects to MySQL/MariaDB for a `mysql:` DSN. Returns the connection or an - /// error message for `last_open_error`. - pub fn open(dsn: &str) -> Result { - let opts = build_opts(dsn)?; + /// Connects to MySQL/MariaDB for a `mysql:` DSN. `init_command` (P1-9), when + /// non-empty, is one SQL statement run by the server immediately after + /// authentication on every (re)connect — the bridge-level minimal wiring for + /// `Pdo\Mysql::ATTR_INIT_COMMAND` (Doctrine/Laravel commonly set `SET NAMES + /// utf8mb4` or a `sql_mode` here). It travels as its own parameter rather than + /// a DSN `key=value` pair because the DSN parser splits on `;`, which a + /// realistic init command (e.g. two statements) could contain. A DSN + /// `charset=` key (P2-3) becomes its own `SET NAMES ` statement, run + /// before `init_command` so an explicit `ATTR_INIT_COMMAND` can still issue + /// its own `SET NAMES`/`sql_mode` afterwards in the same session. + /// + /// `ssl_config` is the prelude's packed serialization of the + /// `Pdo\Mysql::ATTR_SSL_*` constructor options (`ca=…;cert=…;key=…;verify=…`); + /// an empty string means no TLS. It is honored when the default `mysql-tls` + /// feature is compiled in (see [`apply_ssl_opts`]). + /// + /// `found_rows` is the `Pdo\Mysql::ATTR_FOUND_ROWS` constructor option + /// (F-MY-06): it adds `CLIENT_FOUND_ROWS` to the capabilities negotiated in the + /// handshake, so an UPDATE's `rowCount()` reports the rows its WHERE clause + /// MATCHED rather than the rows it actually CHANGED. It can only be applied at + /// connect time (see [`build_opts`]). Returns the connection or an error message + /// for `last_open_error`. + pub fn open( + dsn: &str, + init_command: &str, + ssl_config: &str, + found_rows: bool, + driver_config: &str, + ) -> Result { + let driver_options = parse_driver_options(driver_config)?; + let (mut opts, charset) = + build_opts(dsn, found_rows, driver_options.ignore_space)?; + let (ssl_config, _ca_bundle) = + normalize_ssl_ca_sources(ssl_config, driver_options.ssl_ca_path.as_deref())?; + if let Some(public_key) = driver_options.server_public_key.clone() { + opts = opts.server_public_key_path(Some(public_key)); + } + opts = apply_ssl_opts(opts, &ssl_config, driver_options.ssl_cipher.as_deref())?; + if driver_options.compress { + opts = opts.compress(Some(mysql::Compression::default())); + } + opts = opts.local_infile_handler(Some(local_infile_handler( + driver_options.local_infile, + driver_options.local_infile_directory.clone(), + ))); + let mut init_statements: Vec = Vec::new(); + if let Some(cs) = charset { + init_statements.push(format!("SET NAMES {cs}")); + } + if !init_command.is_empty() { + init_statements.push(init_command.to_string()); + } + if !init_statements.is_empty() { + opts = opts.init(init_statements); + } + let resolved_opts: mysql::Opts = opts.clone().into(); + let host_info = match resolved_opts.get_socket() { + Some(_) => "Localhost via UNIX socket".to_string(), + None => format!("{} via TCP/IP", resolved_opts.get_ip_or_hostname()), + }; let conn = Conn::new(opts).map_err(|e| e.to_string())?; + let server_version = conn.server_version(); + let no_backslash_escapes = conn.no_backslash_escape(); Ok(MyConn { - conn, + conn: MyClientSlot(Some(conn)), + host_info, changes: 0, errmsg: String::new(), errcode: 0, + sqlstate: "00000".to_string(), last_id: 0, + autocommit: true, + fetch_table_names: false, + buffered_query: driver_options.buffered_query, + multi_statements: driver_options.multi_statements, + unbuffered_active: false, + warning_count: 0, + in_transaction: false, + server_version, + no_backslash_escapes, + active_stream: None, + next_stream_id: 0, }) } + /// Changes the default buffering mode used by statements prepared after this + /// call, matching `Pdo\Mysql::ATTR_USE_BUFFERED_QUERY`'s connection attribute. + pub fn set_buffered_query(&mut self, buffered: bool) -> i64 { + self.buffered_query = buffered; + 1 + } + + /// Returns the current `ATTR_USE_BUFFERED_QUERY` default. + pub fn buffered_query(&self) -> i64 { + self.buffered_query as i64 + } + + /// Records mysqlnd's 2014/HY000 connection-busy diagnostic and returns false + /// while an unbuffered statement still owns unread rows. + fn ensure_not_busy(&mut self) -> bool { + if !self.unbuffered_active { + return true; + } + self.sqlstate = "HY000".to_string(); + self.errcode = 2014; + self.errmsg = "Cannot execute queries while other unbuffered queries are active. Consider using PDOStatement::fetchAll(). Alternatively, if your code is only ever going to run against mysql, you may enable query buffering by setting the PDO::MYSQL_ATTR_USE_BUFFERED_QUERY attribute.".to_string(); + false + } + + /// Restores a worker-owned client and refreshes connection properties that + /// may have changed while its statement ran. + fn restore_stream_client(&mut self, conn: Conn, warnings: u16) { + self.server_version = conn.server_version(); + self.no_backslash_escapes = conn.no_backslash_escape(); + self.warning_count = warnings; + self.conn.0 = Some(conn); + } + + /// Stops the active worker and recovers its client for close/reset paths. + fn finish_active_stream(&mut self) { + let Some(mut active) = self.active_stream.take() else { + return; + }; + let _ = active.commands.send(MyStreamCommand::Close); + while let Ok(response) = active.responses.recv() { + match response { + MyStreamResponse::Finished(conn, warnings) => { + self.restore_stream_client(conn, warnings); + break; + } + MyStreamResponse::Failed(conn, sqlstate, errcode, message) => { + let warnings = conn.warnings(); + self.restore_stream_client(conn, warnings); + self.sqlstate = sqlstate; + self.errcode = errcode; + self.errmsg = message; + break; + } + MyStreamResponse::Rowset(_) + | MyStreamResponse::Row(_) + | MyStreamResponse::RowsetEnd => {} + } + } + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + self.unbuffered_active = false; + } + + /// Finishes the active stream only when it belongs to `id`. + fn finish_stream(&mut self, id: u64) { + if self.active_stream.as_ref().map(|stream| stream.id) == Some(id) { + self.finish_active_stream(); + } + } + + /// Installs a newly spawned worker and waits for its first result-set metadata. + fn activate_stream( + &mut self, + commands: mpsc::Sender, + responses: mpsc::Receiver, + worker: JoinHandle<()>, + ) -> Result<(u64, MyRowset), i64> { + self.next_stream_id = self.next_stream_id.wrapping_add(1).max(1); + let id = self.next_stream_id; + let mut active = MyActiveStream { + id, + commands, + responses, + worker: Some(worker), + }; + match active.responses.recv() { + Ok(MyStreamResponse::Rowset(rowset)) => { + self.active_stream = Some(active); + self.unbuffered_active = true; + Ok((id, rowset)) + } + Ok(MyStreamResponse::Finished(conn, warnings)) => { + self.restore_stream_client(conn, warnings); + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + Err(-1) + } + Ok(MyStreamResponse::Failed(conn, sqlstate, errcode, message)) => { + let warnings = conn.warnings(); + self.restore_stream_client(conn, warnings); + self.sqlstate = sqlstate; + self.errcode = errcode; + self.errmsg = message; + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + Err(-1) + } + Ok(MyStreamResponse::Row(_) | MyStreamResponse::RowsetEnd) | Err(_) => { + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + self.sqlstate = "HY000".to_string(); + self.errcode = 1; + self.errmsg = "MySQL unbuffered query worker terminated unexpectedly".to_string(); + Err(-1) + } + } + } + + /// Starts an unbuffered prepared-statement worker. + fn start_native_stream( + &mut self, + statement: Statement, + values: Vec, + ) -> Result<(u64, MyRowset), i64> { + let Some(conn) = self.conn.0.take() else { + return Err(-1); + }; + let fetch_table_names = self.fetch_table_names; + let (command_tx, command_rx) = mpsc::channel(); + let (response_tx, response_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + run_mysql_native_stream( + conn, + statement, + values, + fetch_table_names, + command_rx, + response_tx, + ); + }); + self.activate_stream(command_tx, response_rx, worker) + } + + /// Starts an unbuffered text-protocol worker for emulated prepares. + fn start_text_stream(&mut self, sql: String) -> Result<(u64, MyRowset), i64> { + let Some(conn) = self.conn.0.take() else { + return Err(-1); + }; + let fetch_table_names = self.fetch_table_names; + let (command_tx, command_rx) = mpsc::channel(); + let (response_tx, response_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + run_mysql_text_stream( + conn, + sql, + fetch_table_names, + command_rx, + response_tx, + ); + }); + self.activate_stream(command_tx, response_rx, worker) + } + + /// Requests one row from the active result set. + fn next_stream_row(&mut self, id: u64) -> Result>, i64> { + let Some(active) = self.active_stream.as_mut() else { + return Ok(None); + }; + if active.id != id { + return Ok(None); + } + if active.commands.send(MyStreamCommand::Next).is_err() { + return Err(-1); + } + match active.responses.recv() { + Ok(MyStreamResponse::Row(row)) => Ok(Some(row)), + Ok(MyStreamResponse::RowsetEnd) => Ok(None), + Ok(MyStreamResponse::Failed(conn, sqlstate, errcode, message)) => { + let warnings = conn.warnings(); + self.restore_stream_client(conn, warnings); + self.sqlstate = sqlstate; + self.errcode = errcode; + self.errmsg = message; + let mut active = self.active_stream.take().expect("active stream disappeared"); + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + self.unbuffered_active = false; + Err(-1) + } + Ok(MyStreamResponse::Finished(conn, warnings)) => { + self.restore_stream_client(conn, warnings); + let mut active = self.active_stream.take().expect("active stream disappeared"); + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + self.unbuffered_active = false; + Ok(None) + } + Ok(MyStreamResponse::Rowset(_)) | Err(_) => Err(-1), + } + } + + /// Activates the next protocol result set, or recovers the connection at EOF. + fn next_stream_rowset(&mut self, id: u64) -> Result, i64> { + let Some(active) = self.active_stream.as_mut() else { + return Ok(None); + }; + if active.id != id { + return Ok(None); + } + match active.responses.recv() { + Ok(MyStreamResponse::Rowset(rowset)) => Ok(Some(rowset)), + Ok(MyStreamResponse::Finished(conn, warnings)) => { + self.restore_stream_client(conn, warnings); + let mut active = self.active_stream.take().expect("active stream disappeared"); + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + self.unbuffered_active = false; + Ok(None) + } + Ok(MyStreamResponse::Failed(conn, sqlstate, errcode, message)) => { + let warnings = conn.warnings(); + self.restore_stream_client(conn, warnings); + self.sqlstate = sqlstate; + self.errcode = errcode; + self.errmsg = message; + let mut active = self.active_stream.take().expect("active stream disappeared"); + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + self.unbuffered_active = false; + Err(-1) + } + Ok(MyStreamResponse::Row(_) | MyStreamResponse::RowsetEnd) | Err(_) => Err(-1), + } + } + /// Records the AUTO_INCREMENT id from the just-run statement when it is /// non-zero, so `lastInsertId()` survives an intervening non-INSERT query. + /// Stored without a lossy cast (P2-2's sibling gap) so a `BIGINT UNSIGNED` + /// AUTO_INCREMENT id above `i64::MAX` still round-trips through + /// `last_insert_id_text`. fn note_last_id(&mut self, id: Option) { if let Some(id) = id { if id != 0 { - self.last_id = id as i64; + self.last_id = id; } } } + /// Updates transaction bookkeeping from a successfully executed SQL command. + fn note_transaction_sql(&mut self, sql: &str) { + self.in_transaction = transaction_state_after_sql(sql, self.in_transaction, self.autocommit); + } + /// Runs a statement with no result rows (`PDO::exec`), returning the affected /// row count or `-1` on error. pub fn exec(&mut self, sql: &str) -> i64 { + if !self.ensure_not_busy() { + return -1; + } + let no_backslash_escape = self.no_backslash_escape(); + if !self.multi_statements && sql_has_multiple_statements(sql, no_backslash_escape) { + self.sqlstate = "42000".to_string(); + self.errcode = 1064; + self.errmsg = "Multiple statements are disabled for this connection".to_string(); + return -1; + } // Collect the outcome into owned values first so the `&mut self.conn` // borrow held by the query result ends before the connection bookkeeping // fields are written below. let outcome: Result<(i64, Option), mysql::Error> = match self.conn.query_iter(sql) { Ok(mut res) => { + // P0-B: `last_insert_id`/`affected_rows` read the CURRENT result + // set's OK packet, which only exists while the state machine is + // still on that set. The first `.next()` call below on an + // empty-result (DDL/DML) query advances the state straight to + // `Done` (no OK packet), so `affected_rows()` read AFTER the + // drain loop always returns 0. Capture both here, immediately + // after the query succeeds and before draining, so the real + // counts survive the state transition (verified live on + // MariaDB 11: reading before the drain gives the correct count, + // after gives 0). let last = res.last_insert_id(); + let affected = res.affected_rows() as i64; // Drain any result set (a SELECT run through exec() has rows; DDL // and DML have none) so the connection is ready for the next call. for row in res.by_ref() { @@ -299,19 +1912,23 @@ impl MyConn { break; } } - let affected = res.affected_rows() as i64; Ok((affected, last)) } Err(e) => Err(e), }; + let warnings = self.conn.warnings(); match outcome { Ok((affected, last)) => { self.note_last_id(last); self.changes = affected; self.errcode = 0; + self.sqlstate = "00000".to_string(); + self.warning_count = warnings; + self.note_transaction_sql(sql); affected } Err(e) => { + self.sqlstate = err_sqlstate(&e); self.errmsg = e.to_string(); self.errcode = err_code(&e); -1 @@ -321,9 +1938,16 @@ impl MyConn { /// Runs a bare transaction-control statement, returning `1`/`0`. pub fn exec_simple(&mut self, sql: &str) -> i64 { + if !self.ensure_not_busy() { + return 0; + } match self.conn.query_drop(sql) { - Ok(()) => 1, + Ok(()) => { + self.note_transaction_sql(sql); + 1 + } Err(e) => { + self.sqlstate = err_sqlstate(&e); self.errmsg = e.to_string(); self.errcode = err_code(&e); 0 @@ -331,45 +1955,282 @@ impl MyConn { } } + /// Enables or disables MySQL session autocommit. An unchanged value is a + /// successful no-op; a server error leaves the stored state unchanged. + pub fn set_autocommit(&mut self, enabled: bool) -> i64 { + if !self.ensure_not_busy() { + return 0; + } + if self.autocommit == enabled { + return 1; + } + let sql = if enabled { + "SET autocommit=1" + } else { + "SET autocommit=0" + }; + match self.conn.query_drop(sql) { + Ok(()) => { + self.autocommit = enabled; + if enabled { + self.in_transaction = false; + } + self.errcode = 0; + self.sqlstate = "00000".to_string(); + 1 + } + Err(error) => { + self.sqlstate = err_sqlstate(&error); + self.errmsg = error.to_string(); + self.errcode = err_code(&error); + 0 + } + } + } + /// Returns the last inserted AUTO_INCREMENT id. MySQL ignores the sequence - /// name argument (it is a PostgreSQL/Oracle concept). + /// name argument (it is a PostgreSQL/Oracle concept). Matches the bridge's + /// `i64` ABI (`elephc_pdo_last_insert_id`): a `BIGINT UNSIGNED` id above + /// `i64::MAX` still wraps through this accessor — `last_insert_id_text` is + /// the precision-preserving one (mirroring PostgreSQL's own text accessor). pub fn last_insert_id(&self, _name: Option<&str>) -> i64 { - self.last_id + self.last_id as i64 + } + + /// Like `last_insert_id`, but renders the id as decimal text without the + /// lossy `i64` cast (P2-2's sibling gap), so a `BIGINT UNSIGNED` + /// AUTO_INCREMENT id above `i64::MAX` round-trips exactly. MySQL ignores the + /// sequence name argument, matching `last_insert_id`. + pub fn last_insert_id_text(&self, _name: Option<&str>) -> String { + self.last_id.to_string() + } + + /// Returns the MySQL/MariaDB server's reported version (`MAJOR.MINOR.PATCH`), + /// parsed from the handshake by the `mysql` client. + pub fn server_version(&self) -> String { + let (major, minor, patch) = self.server_version; + format!("{major}.{minor}.{patch}") + } + + /// Returns the pure-Rust MySQL client implementation and its pinned crate + /// version. Unlike php-src there is no mysqlnd/libmysql client library in the + /// standalone binary, so reporting the linked client crate is the truthful + /// equivalent of `mysql_get_client_info()`. + pub fn client_version(&self) -> String { + "mysql 28.0.0".to_string() + } + + /// Returns the connection transport description in the same shape as + /// php-src's `mysql_get_host_info()` result. + pub fn connection_status(&self) -> String { + self.host_info.clone() + } + + /// Pings an idle client; an active row worker already proves the connection + /// is live enough to remain a valid persistent handle. + pub fn is_alive(&mut self) -> bool { + self.active_stream.is_some() || self.conn.ping().is_ok() + } + + /// Updates the live `PDO::ATTR_FETCH_TABLE_NAMES` setting. + pub fn set_fetch_table_names(&mut self, enabled: bool) { + self.fetch_table_names = enabled; + } + + /// Reconstructs MySQL's `COM_STATISTICS` text from live `SHOW STATUS` values. + /// The Rust client does not expose the protocol command, but the same server + /// counters are available without relying on fabricated constants. + pub fn server_info(&mut self) -> String { + if !self.ensure_not_busy() { + return String::new(); + } + let rows: Vec<(String, String)> = match self.conn.query("SHOW STATUS") { + Ok(rows) => rows, + Err(_) => return String::new(), + }; + let values: HashMap = rows.into_iter().collect(); + let value = |name: &str| values.get(name).map(String::as_str).unwrap_or("0"); + let uptime = value("Uptime").parse::().unwrap_or(0.0); + let questions = value("Questions").parse::().unwrap_or(0.0); + let queries_per_second = if uptime > 0.0 { + questions / uptime + } else { + 0.0 + }; + format!( + "Uptime: {} Threads: {} Questions: {} Slow queries: {} Opens: {} Flush tables: {} Open tables: {} Queries per second avg: {:.3}", + value("Uptime"), + value("Threads_connected"), + value("Questions"), + value("Slow_queries"), + value("Opened_tables"), + value("Flush_commands"), + value("Open_tables"), + queries_per_second, + ) + } + + /// Returns the warning count captured from the final OK/EOF packet of the last + /// completed operation, including SELECT and prepared-statement results. + pub fn warning_count(&self) -> i64 { + self.warning_count as i64 + } + + /// Returns whether the connection's current session has `NO_BACKSLASH_ESCAPES` + /// active in its `sql_mode` (backslash is then a literal character in a string + /// literal, so backslash-escaping a quoted value is unsafe there — + /// `PDO::quote()`'s MySQL branch falls back to `''`-doubling only in that case, + /// P1-f). The `mysql` crate already tracks this from the connection's session + /// state (`Conn::no_backslash_escape`), so no extra query is needed here. + pub fn no_backslash_escape(&self) -> bool { + self.conn + .0 + .as_ref() + .map(Conn::no_backslash_escape) + .unwrap_or(self.no_backslash_escapes) } /// Prepares a statement: translates placeholders and prepares it server-side - /// for column metadata. Returns the statement or an error message. - pub fn prepare(&mut self, sql: &str) -> Result { - let (translated, named_map, order) = translate_placeholders(sql); + /// for column metadata. Returns the statement or an error message. Rejects a + /// SQL text that mixes a positional `?` with a named `:name` placeholder + /// with `HY093` before ever asking the server to prepare it — PDO forbids + /// combining the two styles in one statement, and MySQL's own placeholder + /// syntax (a bare `?`) has no way to catch this itself. + /// + /// F-MY-03: the placeholder scan is handed this connection's LIVE + /// `NO_BACKSLASH_ESCAPES` `sql_mode` (the same session state + /// [`MyConn::no_backslash_escape`] reports to `PDO::quote()`), because that + /// mode changes where the SERVER thinks a `'…'`/`"…"` literal ends — and a + /// scanner that disagrees with the server about that disagrees with it about + /// how many placeholders the statement has. This is the only place the flag can + /// be read: `translate_placeholders` is a free function with no connection. + pub fn prepare(&mut self, sql: &str, emulated: bool) -> Result { + if !self.ensure_not_busy() { + return Err(self.errmsg.clone()); + } + let no_backslash_escape = self.no_backslash_escape(); + if !self.multi_statements && sql_has_multiple_statements(sql, no_backslash_escape) { + self.sqlstate = "42000".to_string(); + self.errcode = 1064; + self.errmsg = "Multiple statements are disabled for this connection".to_string(); + return Err(self.errmsg.clone()); + } + let (translated, named_map, order, mixed) = + translate_placeholders(sql, no_backslash_escape); + if mixed { + // Nonzero native code like every other error path here, and `1` + // specifically to match pg's identical HY093 branch (`pg.rs`, + // `PgConn::prepare`): the same logical error must not report + // `errorInfo()[1] == 0` on MySQL and `1` on PostgreSQL. + self.errcode = 1; + self.sqlstate = "HY093".to_string(); + self.errmsg = + "Invalid parameter number: mixed named and positional parameters".to_string(); + return Err(self.errmsg.clone()); + } + let n_binds = order.iter().copied().max().unwrap_or(0) as usize; + if emulated { + self.errcode = 0; + self.sqlstate = "00000".to_string(); + return Ok(MyStmt { + conn_id: 0, + query_string: sql.to_string(), + statement: None, + emulated_sql: Some(translated), + no_backslash_escape: self.no_backslash_escape(), + sent_sql: String::new(), + named_map, + order, + binds: vec![Bind::Null; n_binds], + bound: vec![false; n_binds], + col_names: Vec::new(), + col_kinds: Vec::new(), + col_types: Vec::new(), + col_tables: Vec::new(), + col_flags: Vec::new(), + col_lengths: Vec::new(), + col_precisions: Vec::new(), + rows: Vec::new(), + remaining_rowsets: Vec::new(), + cursor: -1, + executed: false, + is_call: sql_is_call_statement(sql), + buffered: self.buffered_query, + stream_id: None, + }); + } match self.conn.prep(&translated) { Ok(statement) => { let col_names = statement .columns() .iter() - .map(|c| c.name_str().into_owned()) + .map(|column| column_display_name(column, self.fetch_table_names)) .collect(); let col_kinds = statement .columns() .iter() - .map(|c| ColKind::from_column_type(c.column_type())) + .map(ColKind::from_column) + .collect(); + // F-MY-08: the raw wire type, kept beside the coarser `ColKind` so + // `getColumnMeta`'s `native_type` can report MySQL's own type name. + let col_types = statement + .columns() + .iter() + .map(|c| c.column_type()) + .collect(); + let col_tables = statement + .columns() + .iter() + .map(|column| column.table_str().into_owned()) + .collect(); + let col_flags = statement + .columns() + .iter() + .map(Column::flags) + .collect(); + let col_lengths = statement + .columns() + .iter() + .map(Column::column_length) + .collect(); + let col_precisions = statement + .columns() + .iter() + .map(Column::decimals) .collect(); // Distinct slots run 1..=N contiguously, so the highest slot in // `order` is the bound-value count. - let n_binds = order.iter().copied().max().unwrap_or(0) as usize; + self.errcode = 0; + self.sqlstate = "00000".to_string(); Ok(MyStmt { conn_id: 0, - statement, + query_string: sql.to_string(), + statement: Some(statement), + emulated_sql: None, + no_backslash_escape: self.no_backslash_escape(), + sent_sql: String::new(), named_map, order, binds: vec![Bind::Null; n_binds], + bound: vec![false; n_binds], col_names, col_kinds, + col_types, + col_tables, + col_flags, + col_lengths, + col_precisions, rows: Vec::new(), + remaining_rowsets: Vec::new(), cursor: -1, executed: false, + is_call: sql_is_call_statement(sql), + buffered: self.buffered_query, + stream_id: None, }) } Err(e) => { + self.sqlstate = err_sqlstate(&e); self.errmsg = e.to_string(); self.errcode = err_code(&e); Err(e.to_string()) @@ -378,6 +2239,36 @@ impl MyConn { } } +/// Derives MySQL's transaction state after one successful command. Explicit +/// control statements win; DDL implicitly commits; with autocommit disabled a +/// regular statement starts the session transaction. +pub(crate) fn transaction_state_after_sql( + sql: &str, + current: bool, + autocommit: bool, +) -> bool { + let normalized = sql.trim_start().to_ascii_uppercase(); + if normalized.starts_with("BEGIN") || normalized.starts_with("START TRANSACTION") { + return true; + } + if normalized.starts_with("COMMIT") || normalized.starts_with("END") { + return normalized.contains("AND CHAIN"); + } + if normalized.starts_with("ROLLBACK") { + if normalized.starts_with("ROLLBACK TO") { + return current; + } + return normalized.contains("AND CHAIN"); + } + if ["ALTER", "CREATE", "DROP", "GRANT", "LOCK", "RENAME", "REVOKE", "TRUNCATE", "UNLOCK"] + .iter() + .any(|keyword| normalized.starts_with(keyword)) + { + return false; + } + if autocommit { current } else { true } +} + /// Renders a MySQL `DATE`/`DATETIME`/`TIMESTAMP` value as its canonical text: /// date-only columns drop the time, others keep `H:M:S` (with a fractional part /// only when present), matching the server's string output. @@ -412,7 +2303,17 @@ fn decode_value(v: Value, kind: ColKind) -> Cell { match v { Value::NULL => Cell::Null, Value::Int(i) => Cell::Int(i), - Value::UInt(u) => Cell::Int(u as i64), + // BIGINT UNSIGNED (P2-2): a value above `i64::MAX` would wrap negative + // through a plain `as i64` cast, silently corrupting the column. PHP's + // pdo_mysql/mysqlnd matches this numeric-string fallback for any integer + // too large for a native `zend_long`. + Value::UInt(u) => { + if u > i64::MAX as u64 { + Cell::Text(u.to_string()) + } else { + Cell::Int(u as i64) + } + } Value::Float(f) => Cell::Float(f as f64), Value::Double(d) => Cell::Float(d), // Text, VARCHAR, DECIMAL, BLOB, etc. all arrive as raw bytes. @@ -440,6 +2341,129 @@ fn decode_row(values: Vec, kinds: &[ColKind]) -> Vec { .collect() } +/// Builds an empty rowset carrying the current MySQL protocol set's metadata. +fn mysql_stream_rowset( + columns: &[Column], + affected: i64, + last_id: Option, + fetch_table_names: bool, +) -> MyRowset { + MyRowset { + affected, + last_id, + col_names: columns + .iter() + .map(|column| column_display_name(column, fetch_table_names)) + .collect(), + col_kinds: columns.iter().map(ColKind::from_column).collect(), + col_types: columns.iter().map(Column::column_type).collect(), + col_tables: columns + .iter() + .map(|column| column.table_str().into_owned()) + .collect(), + col_flags: columns.iter().map(Column::flags).collect(), + col_lengths: columns.iter().map(Column::column_length).collect(), + col_precisions: columns.iter().map(Column::decimals).collect(), + rows: Vec::new(), + } +} + +/// Drives an already-started MySQL query one row per `Next` command, preserving +/// protocol result-set boundaries for `PDOStatement::nextRowset()`. +fn drive_mysql_stream( + mut result: QueryResult<'_, '_, '_, T>, + fetch_table_names: bool, + commands: &mpsc::Receiver, + responses: &mpsc::Sender, +) -> Result<(), mysql::Error> { + while let Some(mut set) = result.iter() { + let columns = set.columns(); + let columns: &[Column] = columns.as_ref(); + let kinds = columns.iter().map(ColKind::from_column).collect::>(); + let rowset = mysql_stream_rowset( + columns, + set.affected_rows() as i64, + set.last_insert_id(), + fetch_table_names, + ); + if responses.send(MyStreamResponse::Rowset(rowset)).is_err() { + return Ok(()); + } + while let Ok(command) = commands.recv() { + match command { + MyStreamCommand::Next => match set.next() { + Some(row) => { + let decoded = decode_row(row?.unwrap(), &kinds); + if responses.send(MyStreamResponse::Row(decoded)).is_err() { + return Ok(()); + } + } + None => { + let _ = responses.send(MyStreamResponse::RowsetEnd); + break; + } + }, + MyStreamCommand::Close => return Ok(()), + } + } + } + Ok(()) +} + +/// Runs a prepared MySQL query on an owned client and returns that client when +/// demand-driven iteration finishes. +fn run_mysql_native_stream( + mut conn: Conn, + statement: Statement, + values: Vec, + fetch_table_names: bool, + commands: mpsc::Receiver, + responses: mpsc::Sender, +) { + let result = match conn.exec_iter(&statement, values) { + Ok(result) => drive_mysql_stream(result, fetch_table_names, &commands, &responses), + Err(error) => Err(error), + }; + finish_mysql_stream_worker(conn, result, &responses); +} + +/// Runs an emulated/text-protocol MySQL query on an owned client. +fn run_mysql_text_stream( + mut conn: Conn, + sql: String, + fetch_table_names: bool, + commands: mpsc::Receiver, + responses: mpsc::Sender, +) { + let result = match conn.query_iter(sql) { + Ok(result) => drive_mysql_stream(result, fetch_table_names, &commands, &responses), + Err(error) => Err(error), + }; + finish_mysql_stream_worker(conn, result, &responses); +} + +/// Sends a worker's recovered connection plus final warning/error state. +fn finish_mysql_stream_worker( + conn: Conn, + result: Result<(), mysql::Error>, + responses: &mpsc::Sender, +) { + match result { + Ok(()) => { + let warnings = conn.warnings(); + let _ = responses.send(MyStreamResponse::Finished(conn, warnings)); + } + Err(error) => { + let sqlstate = err_sqlstate(&error); + let errcode = err_code(&error); + let message = error.to_string(); + let _ = responses.send(MyStreamResponse::Failed( + conn, sqlstate, errcode, message, + )); + } + } +} + impl MyStmt { /// Resolves a named placeholder to its 1-based slot (0 if unknown). The /// leading colon is optional. @@ -454,21 +2478,83 @@ impl MyStmt { return 0; } self.binds[(idx - 1) as usize] = value; + self.bound[(idx - 1) as usize] = true; 1 } /// Resets the cursor and execution state, keeping the bound values. - pub fn reset(&mut self) -> i64 { + pub fn reset(&mut self, conn: &mut MyConn) -> i64 { + if let Some(stream_id) = self.stream_id { + conn.finish_stream(stream_id); + } self.cursor = -1; self.executed = false; self.rows.clear(); + self.remaining_rowsets.clear(); + self.stream_id = None; + 1 + } + + /// Makes one materialized result set active and updates connection-level + /// row-count/insert-id state to match it. + fn install_rowset(&mut self, conn: &mut MyConn, rowset: MyRowset) { + conn.changes = if !self.buffered && !rowset.col_names.is_empty() { + 0 + } else { + rowset.row_count() + }; + conn.note_last_id(rowset.last_id); + self.col_names = rowset.col_names; + self.col_kinds = rowset.col_kinds; + self.col_types = rowset.col_types; + self.col_tables = rowset.col_tables; + self.col_flags = rowset.col_flags; + self.col_lengths = rowset.col_lengths; + self.col_precisions = rowset.col_precisions; + self.rows = rowset.rows; + self.cursor = -1; + self.executed = true; + conn.unbuffered_active = !self.buffered + && (self.stream_id.is_some() || !self.rows.is_empty()); + } + + /// Advances to the next MySQL result set, discarding unread streamed rows as + /// mysqlnd does. Returns `1` when a rowset became active and `0` at protocol EOF. + pub fn next_rowset(&mut self, conn: &mut MyConn) -> i64 { + if let Some(stream_id) = self.stream_id { + if self.remaining_rowsets.is_empty() { + loop { + match conn.next_stream_row(stream_id) { + Ok(Some(_)) => {} + Ok(None) => break, + Err(code) => return code, + } + } + match conn.next_stream_rowset(stream_id) { + Ok(Some(rowset)) => self.remaining_rowsets.push(rowset), + Ok(None) => self.stream_id = None, + Err(code) => return code, + } + } + self.rows.clear(); + self.cursor = -1; + } + if self.remaining_rowsets.is_empty() { + if self.stream_id.is_none() { + conn.unbuffered_active = false; + } + return 0; + } + let rowset = self.remaining_rowsets.remove(0); + self.install_rowset(conn, rowset); 1 } /// Clears all bound values back to NULL. pub fn clear_bindings(&mut self) -> i64 { - for b in &mut self.binds { + for (b, bound) in self.binds.iter_mut().zip(self.bound.iter_mut()) { *b = Bind::Null; + *bound = false; } 1 } @@ -483,46 +2569,140 @@ impl MyStmt { Bind::Int(v) => Value::Int(*v), Bind::Float(v) => Value::Double(*v), Bind::Text(s) => Value::Bytes(s.clone().into_bytes()), + Bind::NationalText(s) => Value::Bytes(s.clone().into_bytes()), + Bind::Bytes(b) => Value::Bytes(b.clone()), }) .collect() } - /// Executes the query (once) and materializes the result set into decoded - /// cells. Records the affected row count and last insert id on the connection. + /// Builds one flag per emitted positional marker, identifying national-text + /// values for MySQL's emulated `N'…'` literal syntax. + fn build_national_flags(&self) -> Vec { + self.order + .iter() + .map(|&slot| matches!(&self.binds[(slot - 1) as usize], Bind::NationalText(_))) + .collect() + } + + /// Executes the query once, either buffering decoded rows or starting the + /// demand worker. Records row count and last insert id on the connection. + /// + /// P0-C: row materialization is decided from the EXECUTED result's live + /// column metadata (`res.columns()`), not from `self.col_kinds` captured at + /// PREPARE time. MySQL's `COM_STMT_PREPARE` reports zero columns for a + /// `CALL proc()` statement — the result shape is only known once the + /// procedure actually runs — so gating on the prepare-time count made + /// `is_select` false for every `CALL`, silently dropping the procedure's + /// rows off the wire. `res.columns()` must be read here, before the drain + /// loop below: the crate's `QueryResult` is a state machine, and once the + /// current result set's rows are fully consumed its `columns()` reports + /// none, so metadata has to be captured while still on the just-executed + /// set. When the live result has columns, `self.col_names`/`self.col_kinds` + /// are refreshed from it too, so `columnCount()`/`getColumnMeta()` reflect + /// the procedure's real output columns rather than the empty prepare-time + /// set. A genuine non-SELECT (e.g. an `INSERT` via a prepared statement) + /// still reports zero live columns, so it keeps taking the + /// `affected_rows()` path below unchanged. + /// + /// This alone is not sufficient, though: the generated `PDOStatement:: + /// execute()` prelude reads `column_count()` BEFORE ever calling `step()` + /// (and thus before this method has run even once) to decide whether the + /// upcoming first `step()` is a throwaway "run the DML" call or a real + /// "pre-fetch the first row" call whose result must be cached. A `CALL`'s + /// prepare-time column count is genuinely `0` at that point (this method + /// has not run yet to refresh it), so without `column_count()`'s own + /// `is_call` placeholder (see its doc comment), the prelude picks the + /// throwaway branch and discards this method's very first materialized row. fn execute(&mut self, conn: &mut MyConn) -> Result<(), i64> { + if self.emulated_sql.is_some() { + return self.execute_emulated(conn); + } let values = self.build_values(); - let statement = self.statement.clone(); - // A statement with result columns is a SELECT-style query; otherwise it is - // DML/DDL whose affected-row count and insert id we record. - let is_select = !self.col_kinds.is_empty(); - let col_kinds = self.col_kinds.clone(); - let outcome: Result<(i64, Option, Vec>), mysql::Error> = (|| { + let statement = self + .statement + .as_ref() + .expect("native MySQL statement missing its prepared handle") + .clone(); + if !self.buffered { + let (stream_id, rowset) = conn.start_native_stream(statement, values)?; + self.stream_id = Some(stream_id); + self.remaining_rowsets.clear(); + self.install_rowset(conn, rowset); + conn.note_transaction_sql(&self.query_string); + return Ok(()); + } + let outcome: Result, mysql::Error> = (|| { let mut res = conn.conn.exec_iter(&statement, values)?; - let last = res.last_insert_id(); - let mut rows = Vec::new(); - if is_select { - for row in res.by_ref() { + let mut rowsets = Vec::new(); + while let Some(mut set) = res.iter() { + let last_id = set.last_insert_id(); + let affected = set.affected_rows() as i64; + let live = set.columns(); + let cols: &[Column] = live.as_ref(); + let col_names = cols + .iter() + .map(|column| column_display_name(column, conn.fetch_table_names)) + .collect::>(); + let col_kinds = cols.iter().map(ColKind::from_column).collect::>(); + let col_types = cols + .iter() + .map(|column| column.column_type()) + .collect::>(); + let col_tables = cols + .iter() + .map(|column| column.table_str().into_owned()) + .collect::>(); + let col_flags = cols.iter().map(Column::flags).collect::>(); + let col_lengths = cols.iter().map(Column::column_length).collect::>(); + let col_precisions = cols.iter().map(Column::decimals).collect::>(); + let mut rows = Vec::new(); + for row in set.by_ref() { rows.push(decode_row(row?.unwrap(), &col_kinds)); } + rowsets.push(MyRowset { + affected, + last_id, + col_names, + col_kinds, + col_types, + col_tables, + col_flags, + col_lengths, + col_precisions, + rows, + }); } - let affected = res.affected_rows() as i64; - drop(res); - Ok((affected, last, rows)) + Ok(rowsets) })(); + let warnings = conn.conn.warnings(); match outcome { - Ok((affected, last, rows)) => { - conn.changes = if is_select { - rows.len() as i64 + Ok(mut rowsets) => { + conn.errcode = 0; + conn.sqlstate = "00000".to_string(); + conn.warning_count = warnings; + let first = if rowsets.is_empty() { + MyRowset { + affected: 0, + last_id: None, + col_names: self.col_names.clone(), + col_kinds: self.col_kinds.clone(), + col_types: self.col_types.clone(), + col_tables: self.col_tables.clone(), + col_flags: self.col_flags.clone(), + col_lengths: self.col_lengths.clone(), + col_precisions: self.col_precisions.clone(), + rows: Vec::new(), + } } else { - affected + rowsets.remove(0) }; - conn.note_last_id(last); - conn.errcode = 0; - self.rows = rows; - self.executed = true; + self.remaining_rowsets = rowsets; + self.install_rowset(conn, first); + conn.note_transaction_sql(&self.query_string); Ok(()) } Err(e) => { + conn.sqlstate = err_sqlstate(&e); conn.errmsg = e.to_string(); conn.errcode = err_code(&e); Err(-1) @@ -530,6 +2710,124 @@ impl MyStmt { } } + /// Executes an emulated MySQL statement through the text protocol after + /// client-side placeholder substitution and materializes its first result. + fn execute_emulated(&mut self, conn: &mut MyConn) -> Result<(), i64> { + if self.bound.iter().any(|bound| !bound) { + conn.sqlstate = "HY093".to_string(); + conn.errcode = 1; + conn.errmsg = "Invalid parameter number: number of bound variables does not match number of tokens".to_string(); + return Err(-1); + } + let values = self.build_values(); + let national = self.build_national_flags(); + let sql = match interpolate_emulated_sql( + self.emulated_sql + .as_deref() + .expect("emulated MySQL statement missing SQL"), + &values, + &national, + self.no_backslash_escape, + ) { + Ok(sql) => sql, + Err(message) => { + conn.sqlstate = "HY093".to_string(); + conn.errcode = 1; + conn.errmsg = message; + return Err(-1); + } + }; + self.sent_sql = sql.clone(); + if !self.buffered { + let (stream_id, rowset) = conn.start_text_stream(sql)?; + self.stream_id = Some(stream_id); + self.remaining_rowsets.clear(); + self.install_rowset(conn, rowset); + conn.note_transaction_sql(&self.query_string); + return Ok(()); + } + let outcome = (|| { + let mut result = conn.conn.query_iter(sql)?; + let mut rowsets = Vec::new(); + while let Some(mut set) = result.iter() { + let last_id = set.last_insert_id(); + let affected = set.affected_rows() as i64; + let columns = set.columns(); + let columns: &[Column] = columns.as_ref(); + let col_names = columns + .iter() + .map(|column| column_display_name(column, conn.fetch_table_names)) + .collect::>(); + let col_kinds = columns.iter().map(ColKind::from_column).collect::>(); + let col_types = columns + .iter() + .map(|column| column.column_type()) + .collect::>(); + let col_tables = columns + .iter() + .map(|column| column.table_str().into_owned()) + .collect::>(); + let col_flags = columns.iter().map(Column::flags).collect::>(); + let col_lengths = columns + .iter() + .map(Column::column_length) + .collect::>(); + let col_precisions = columns.iter().map(Column::decimals).collect::>(); + let mut rows = Vec::new(); + for row in set.by_ref() { + rows.push(decode_row(row?.unwrap(), &col_kinds)); + } + rowsets.push(MyRowset { + affected, + last_id, + col_names, + col_kinds, + col_types, + col_tables, + col_flags, + col_lengths, + col_precisions, + rows, + }); + } + Ok::<_, mysql::Error>(rowsets) + })(); + let warnings = conn.conn.warnings(); + match outcome { + Ok(mut rowsets) => { + conn.errcode = 0; + conn.sqlstate = "00000".to_string(); + conn.warning_count = warnings; + let first = if rowsets.is_empty() { + MyRowset { + affected: 0, + last_id: None, + col_names: Vec::new(), + col_kinds: Vec::new(), + col_types: Vec::new(), + col_tables: Vec::new(), + col_flags: Vec::new(), + col_lengths: Vec::new(), + col_precisions: Vec::new(), + rows: Vec::new(), + } + } else { + rowsets.remove(0) + }; + self.remaining_rowsets = rowsets; + self.install_rowset(conn, first); + conn.note_transaction_sql(&self.query_string); + Ok(()) + } + Err(error) => { + conn.sqlstate = err_sqlstate(&error); + conn.errmsg = error.to_string(); + conn.errcode = err_code(&error); + Err(-1) + } + } + } + /// Advances to the next row: `1` for a row, `0` when exhausted, `-1` on error. /// Executes lazily on the first call. pub fn step(&mut self, conn: &mut MyConn) -> i64 { @@ -538,10 +2836,32 @@ impl MyStmt { return code; } } + if let Some(stream_id) = self.stream_id { + return match conn.next_stream_row(stream_id) { + Ok(Some(row)) => { + self.rows.clear(); + self.rows.push(row); + self.cursor = 0; + 1 + } + Ok(None) => { + self.rows.clear(); + self.cursor = 0; + match conn.next_stream_rowset(stream_id) { + Ok(Some(rowset)) => self.remaining_rowsets.push(rowset), + Ok(None) => self.stream_id = None, + Err(code) => return code, + } + 0 + } + Err(code) => code, + }; + } self.cursor += 1; if (self.cursor as usize) < self.rows.len() { 1 } else { + conn.unbuffered_active = false; 0 } } @@ -557,8 +2877,31 @@ impl MyStmt { } /// Number of result columns (available before execution). + /// + /// P0-C: for an unexecuted `CALL` (`self.is_call`, `self.col_names` still + /// empty), this reports a placeholder `1` instead of the genuine prepare-time + /// `0`. The generated `PDOStatement::execute()` prelude reads this count + /// right before its first `step()` to decide which of two branches to take: + /// a `0` means "no-result statement" (INSERT/UPDATE/DELETE/DDL) — it runs one + /// throwaway `step()` and does not cache the result for the caller's first + /// `fetch()`; a non-zero count means "SELECT-style" — it caches that same + /// first `step()`'s row so no fetch ever skips it. A `CALL`'s real column + /// count is not known until it actually runs (`execute()` below refreshes + /// `col_names`/`col_kinds` from the live result), so reporting the genuine `0` + /// here would misroute a row-producing `CALL` into the no-result branch, + /// silently discarding the very first row it returns — the observed bug this + /// hack fixes. Once executed, `col_names` reflects the real (possibly still + /// zero, for a `CALL` with no internal `SELECT`) count and this reports it + /// unconditionally — the placeholder only ever applies pre-execution. pub fn column_count(&self) -> i64 { - self.col_names.len() as i64 + if (self.is_call || self.emulated_sql.is_some()) + && !self.executed + && self.col_names.is_empty() + { + 1 + } else { + self.col_names.len() as i64 + } } /// Name of result column `i` (0-based). @@ -566,6 +2909,74 @@ impl MyStmt { self.col_names.get(i as usize).cloned().unwrap_or_default() } + /// MySQL native type name of result column `i` (0-based) — the server's own + /// name for the column's wire type (`LONG`, `VAR_STRING`, `NEWDECIMAL`, `BIT`, + /// `JSON`, `TIMESTAMP`, …), exactly as php-src's `type_to_name_native` spells + /// it (`ext/pdo_mysql/mysql_statement.c:716-770`; see [`native_type_name`]). + /// Backs `getColumnMeta`'s `native_type` on a `mysql:` statement (F-MY-08), + /// which until now fell through to the generic SQLite storage-class names + /// ("integer"/"double"/"string") — the wrong vocabulary for this driver, and + /// one that cannot distinguish a `VARCHAR` from a `BLOB` from a `DECIMAL`. + /// + /// Read from the column descriptor rather than a live cell (mirroring the pg + /// accessor), so it reports the column's DECLARED type whether or not a row is + /// active — a NULL value never degrades it to a runtime storage class. + /// + /// Empty string for an out-of-range index, and for the wire types php-src + /// itself has no name for (where it omits the `native_type` key entirely): + /// `""` is the neutral "no metadata / not this driver" value the bridge's + /// dispatch already uses. + pub fn column_native_type(&self, i: i64) -> String { + if i < 0 { + return String::new(); + } + self.col_types + .get(i as usize) + .map(|&t| native_type_name(t).to_string()) + .unwrap_or_default() + } + + /// Returns the server-provided table label for result column `i`. + pub fn column_table_name(&self, i: i64) -> String { + if i < 0 { + return String::new(); + } + self.col_tables.get(i as usize).cloned().unwrap_or_default() + } + + /// Returns the raw MySQL `ColumnFlags` bits for result column `i`. + pub fn column_flags(&self, i: i64) -> i64 { + if i < 0 { + return 0; + } + self.col_flags + .get(i as usize) + .map(|flags| i64::from(flags.bits())) + .unwrap_or(0) + } + + /// Returns MySQL's declared maximum column byte length. + pub fn column_len(&self, i: i64) -> i64 { + if i < 0 { + return 0; + } + self.col_lengths + .get(i as usize) + .map(|length| i64::from(*length)) + .unwrap_or(0) + } + + /// Returns MySQL's native decimals/precision marker for the column. + pub fn column_precision(&self, i: i64) -> i64 { + if i < 0 { + return 0; + } + self.col_precisions + .get(i as usize) + .map(|precision| i64::from(*precision)) + .unwrap_or(0) + } + /// SQLite-compatible type code for the current row's column `i`: /// 1=int, 2=float, 3=text, 4=blob, 5=null. pub fn column_type(&self, i: i64) -> i64 { @@ -600,17 +3011,6 @@ impl MyStmt { } } - /// Current row's column `i` as text. - pub fn column_text(&self, i: i64) -> String { - match self.cell(i) { - Some(Cell::Text(s)) => s.clone(), - Some(Cell::Bytes(b)) => String::from_utf8_lossy(b).into_owned(), - Some(Cell::Int(v)) => v.to_string(), - Some(Cell::Float(v)) => v.to_string(), - _ => String::new(), - } - } - /// Current row's column `i` as byte-counted PDO data. pub fn column_data(&self, i: i64) -> Vec { match self.cell(i) { @@ -622,3 +3022,471 @@ impl MyStmt { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Generic PDO scanning keeps SQL Server `#temp` identifiers visible and + /// still rewrites a named marker that follows them. + #[test] + fn generic_pdo_placeholders_support_sqlsrv_temp_tables() { + let (sql, named, order, mixed) = translate_pdo_placeholders( + "INSERT INTO #elephc_sqlsrv_bridge(label) VALUES (:label)", + ); + assert_eq!( + sql, + "INSERT INTO #elephc_sqlsrv_bridge(label) VALUES (?)" + ); + assert_eq!(named.get("label"), Some(&1)); + assert_eq!(order, vec![1]); + assert!(!mixed); + } + + /// Emulated interpolation skips quoted/comment markers and escapes a real + /// placeholder value through mysql_common's protocol-aware literal renderer. + #[test] + fn emulated_interpolation_replaces_only_real_placeholders() { + let (sql, _, order, mixed) = translate_placeholders( + "SELECT '?', /* ? */ :first, :name, ??", + false, + ); + assert!(!mixed); + assert_eq!(order, vec![1, 2]); + let rendered = interpolate_emulated_sql( + &sql, + &[Value::Bytes(b"O'Reilly".to_vec()), Value::Int(7)], + &[false, false], + false, + ) + .expect("emulated SQL renders"); + assert_eq!(rendered, "SELECT '?', /* ? */ 'O\\'Reilly', 7, ??"); + } + + /// National string parameters add the `N` introducer only to the matching + /// emulated placeholder and leave ordinary strings unchanged. + #[test] + fn emulated_interpolation_marks_national_strings() { + let rendered = interpolate_emulated_sql( + "SELECT ?, ?", + &[Value::Bytes(b"national".to_vec()), Value::Bytes(b"plain".to_vec())], + &[true, false], + false, + ) + .expect("emulated national SQL renders"); + assert_eq!(rendered, "SELECT N'national', 'plain'"); + } + + /// Extracts the `Cell::Text` payload, or fails naming the wrong variant (no + /// `Debug` derive on `Cell` elsewhere in the bridge, so this keeps the tests + /// below from requiring one just for a panic message). + fn expect_text(cell: Cell) -> String { + match cell { + Cell::Text(s) => s, + Cell::Int(_) => panic!("expected Cell::Text, got Cell::Int"), + Cell::Null => panic!("expected Cell::Text, got Cell::Null"), + Cell::Float(_) => panic!("expected Cell::Text, got Cell::Float"), + Cell::Bytes(_) => panic!("expected Cell::Text, got Cell::Bytes"), + } + } + + /// Extracts the `Cell::Int` payload, or fails naming the wrong variant. + fn expect_int(cell: Cell) -> i64 { + match cell { + Cell::Int(v) => v, + Cell::Text(_) => panic!("expected Cell::Int, got Cell::Text"), + Cell::Null => panic!("expected Cell::Int, got Cell::Null"), + Cell::Float(_) => panic!("expected Cell::Int, got Cell::Float"), + Cell::Bytes(_) => panic!("expected Cell::Int, got Cell::Bytes"), + } + } + + /// P2-2: a `BIGINT UNSIGNED` value at or below `i64::MAX` decodes as a plain + /// `Cell::Int` — the common case, unaffected by the overflow fix below. + #[test] + fn bigint_unsigned_within_i64_max_decodes_to_int() { + let u = i64::MAX as u64; + assert_eq!(expect_int(decode_value(Value::UInt(u), ColKind::Other)), i64::MAX); + } + + /// P2-2 (mandatory unit test, no server needed): a `BIGINT UNSIGNED` value + /// above `i64::MAX` must decode to the exact decimal numeric string rather + /// than silently wrapping negative through an `as i64` cast. + #[test] + fn bigint_unsigned_above_i64_max_decodes_to_numeric_string() { + let u = u64::MAX; + assert_eq!( + expect_text(decode_value(Value::UInt(u), ColKind::Other)), + "18446744073709551615" + ); + // The tightest regression check for the `>` boundary comparison: one + // past `i64::MAX` must already take the text path. + let boundary = i64::MAX as u64 + 1; + assert_eq!( + expect_text(decode_value(Value::UInt(boundary), ColKind::Other)), + boundary.to_string() + ); + } + + /// P0-D (mandatory unit test, no server needed): `BIT` and `GEOMETRY` + /// columns must classify as `ColKind::Binary` so `decode_value` routes them + /// through the byte-preserving `Cell::Bytes` path instead of the lossy + /// `String::from_utf8_lossy` path used for `ColKind::Other` — otherwise a + /// `BIT(8)` value like `0xFF` decodes as a 3-byte U+FFFD replacement + /// character instead of the original byte. Neither type depends on the + /// character set, so the default (0) is left as-is. + #[test] + fn bit_and_geometry_columns_classify_as_binary() { + assert_eq!( + ColKind::from_column(&Column::new(ColumnType::MYSQL_TYPE_BIT)), + ColKind::Binary + ); + assert_eq!( + ColKind::from_column(&Column::new(ColumnType::MYSQL_TYPE_GEOMETRY)), + ColKind::Binary + ); + } + + /// P1 (mandatory unit test, no server needed): `VARBINARY`/`BINARY` columns + /// arrive as `MYSQL_TYPE_VAR_STRING`/`MYSQL_TYPE_STRING` — the exact same + /// `ColumnType` a `VARCHAR`/`CHAR` column uses — so only the charset-63 + /// (`binary` collation) marker tells them apart. A charset-63 `VAR_STRING`/ + /// `STRING` column must classify as `ColKind::Binary`; the same `ColumnType` + /// under a real text charset (e.g. utf8mb4 = 45) must classify as `Other`, so + /// a genuine `VARCHAR`/`CHAR` keeps decoding through the text path. + #[test] + fn varbinary_and_binary_columns_classify_by_charset_not_type() { + let varbinary = Column::new(ColumnType::MYSQL_TYPE_VAR_STRING) + .with_character_set(MYSQL_BINARY_CHARSET); + assert_eq!(ColKind::from_column(&varbinary), ColKind::Binary); + + let binary = + Column::new(ColumnType::MYSQL_TYPE_STRING).with_character_set(MYSQL_BINARY_CHARSET); + assert_eq!(ColKind::from_column(&binary), ColKind::Binary); + + let varchar_utf8mb4 = + Column::new(ColumnType::MYSQL_TYPE_VAR_STRING).with_character_set(45); + assert_eq!(ColKind::from_column(&varchar_utf8mb4), ColKind::Other); + } + + /// P0-C (mandatory unit test, no server needed): `sql_is_call_statement` + /// recognizes `CALL` case-insensitively and with leading whitespace, but + /// rejects a bare `CALLBACK(...)`-style identifier that merely starts with + /// the same four letters, and any non-`CALL` statement. + #[test] + fn sql_is_call_statement_detects_call_only() { + assert!(sql_is_call_statement("CALL my_call_sp()")); + assert!(sql_is_call_statement(" call my_call_sp(?, ?)")); + assert!(sql_is_call_statement("Call\tmy_call_sp()")); + assert!(!sql_is_call_statement("CALLBACK()")); + assert!(!sql_is_call_statement("SELECT 1")); + assert!(!sql_is_call_statement("INSERT INTO t VALUES (1)")); + assert!(!sql_is_call_statement("")); + } + + /// F-MY-05 (re-opens P0-C for the commented variant): the server ignores + /// whatever leads a statement, so a `CALL` behind an optimizer hint or a note + /// is still a stored-procedure call. Recognizing only past the whitespace left + /// those flagged non-`CALL`, which fed the prelude the genuine (but meaningless) + /// prepare-time column count of `0` and routed a row-producing procedure into + /// the no-result DML branch — dropping its first row. All three comment forms + /// are covered, interleaved with whitespace and with each other. + /// + /// The negative half is the load-bearing one for the skipping logic itself: a + /// leading comment must never make an ordinary statement LOOK like a `CALL`, + /// whether the word appears inside the comment or not. And a bare `--` with no + /// trailing whitespace is not a MySQL comment at all (it is the arithmetic + /// `- -`, unlike PostgreSQL), so nothing behind it may be skipped. An + /// unterminated block comment swallows the rest of the statement, leaving no + /// keyword to test — also not a `CALL`. + #[test] + fn sql_is_call_statement_skips_leading_comments() { + assert!(sql_is_call_statement("/* hint */ CALL p()")); + assert!(sql_is_call_statement("-- note\nCALL p()")); + assert!(sql_is_call_statement("# note\ncall p(?)")); + assert!(sql_is_call_statement(" /* a */ -- b\n\t/* c */\nCALL p()")); + assert!(!sql_is_call_statement("/* CALL */ SELECT 1")); + assert!(!sql_is_call_statement("-- CALL p()\nSELECT 1")); + assert!(!sql_is_call_statement("# CALL p()\nSELECT 1")); + assert!(!sql_is_call_statement("--CALL p()")); + assert!(!sql_is_call_statement("/* unterminated CALL p()")); + } + + /// P2-1: a `connect_timeout=` DSN key (as the prelude folds in + /// alongside `user=`/`password=` when `PDO::ATTR_TIMEOUT` is set) parses into + /// the `mysql` client's `tcp_connect_timeout` option. Pure DSN-parsing logic, + /// no server needed — `build_opts` never dials out. The explicit `5` also + /// proves it WINS over F-CORE-10's 30 s default below. + #[test] + fn build_opts_maps_connect_timeout_dsn_key() { + let (opts, _charset) = + build_opts("mysql:host=localhost;dbname=testdb;connect_timeout=5", false, false).unwrap(); + let opts: mysql::Opts = opts.into(); + assert_eq!(opts.get_tcp_connect_timeout(), Some(Duration::from_secs(5))); + } + + /// F-CORE-10: a DSN with no `connect_timeout` key (and no `PDO::ATTR_TIMEOUT`, + /// which the prelude folds into that same key) still gets php-src's + /// unconditional 30 s connect timeout — `mysql_driver.c:755,784` defaults + /// `PDO_ATTR_TIMEOUT` to 30 and always passes it to + /// `mysql_options(MYSQL_OPT_CONNECT_TIMEOUT, …)`. Leaving it unset (as this + /// previously asserted) fell back on the OS TCP timeout, hanging a connection + /// to a black-holed host far longer than real PHP does. + #[test] + fn build_opts_defaults_connect_timeout_to_30s() { + let (opts, _charset) = + build_opts("mysql:host=localhost;dbname=testdb", false, false).unwrap(); + let opts: mysql::Opts = opts.into(); + assert_eq!( + opts.get_tcp_connect_timeout(), + Some(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS)) + ); + } + + /// Packed PDO driver options preserve supported TLS selections and validate + /// a caller-supplied authentication key path before the handshake. + #[test] + fn driver_options_parse_supported_flags_and_security_paths() { + let options = parse_driver_options( + "local=1;compress=1;ignore=1;multi=0;buffered=0;capath=/tmp/mysql-ca;cipher=ECDHE-RSA-AES128-GCM-SHA256;", + ) + .expect("supported PDO MySQL options should parse"); + assert!(options.local_infile); + assert!(options.compress); + assert!(options.ignore_space); + assert!(!options.multi_statements); + assert!(!options.buffered_query); + assert_eq!(options.ssl_ca_path, Some(PathBuf::from("/tmp/mysql-ca"))); + assert_eq!( + options.ssl_cipher.as_deref(), + Some("ECDHE-RSA-AES128-GCM-SHA256") + ); + let error = parse_driver_options("serverkey=/tmp/key.pem;") + .expect_err("a missing server public key must fail loudly"); + assert!(error.contains("ATTR_SERVER_PUBLIC_KEY")); + } + + /// `ATTR_SSL_CAPATH` is translated from an OpenSSL-style CA directory into + /// the multi-certificate PEM file accepted by the mysql crate's rustls path. + #[test] + fn ssl_capath_builds_a_sorted_temporary_pem_bundle() { + static NEXT_TEST_DIR: AtomicU64 = AtomicU64::new(1); + let id = NEXT_TEST_DIR.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "elephc-pdo-capath-test-{}-{id}", + std::process::id() + )); + fs::create_dir(&dir).expect("create CA directory fixture"); + let direct = dir.join("direct.pem"); + fs::write(&direct, b"-----BEGIN CERTIFICATE-----\nDIRECT\n-----END CERTIFICATE-----\n") + .expect("write direct CA fixture"); + fs::write( + dir.join("02-second.pem"), + b"-----BEGIN CERTIFICATE-----\nSECOND\n-----END CERTIFICATE-----\n", + ) + .expect("write second directory CA fixture"); + fs::write( + dir.join("01-first.crt"), + b"-----BEGIN CERTIFICATE-----\nFIRST\n-----END CERTIFICATE-----\n", + ) + .expect("write first directory CA fixture"); + fs::write(dir.join("README"), b"not a certificate") + .expect("write ignored non-PEM fixture"); + + let (config, bundle) = normalize_ssl_ca_sources( + &format!("ca={};verify=1;", direct.display()), + Some(&dir), + ) + .expect("normalize CA sources"); + let bundle = bundle.expect("CAPATH must create a temporary bundle"); + let contents = fs::read_to_string(&bundle.path).expect("read temporary CA bundle"); + assert!(contents.contains("DIRECT")); + let first = contents.find("FIRST").expect("first CA present"); + let second = contents.find("SECOND").expect("second CA present"); + assert!(first < second, "directory certificates must be deterministic"); + assert!(config.starts_with(&format!("ca={};", bundle.path.display()))); + assert!(config.ends_with("verify=1;")); + + drop(bundle); + fs::remove_dir_all(&dir).expect("remove CA directory fixture"); + } + + /// `ATTR_IGNORE_SPACE` reaches the MySQL handshake capability only when + /// requested, alongside but independently from `ATTR_FOUND_ROWS`. + #[test] + fn build_opts_sets_ignore_space_capability() { + let (opts, _) = + build_opts("mysql:host=localhost;dbname=testdb", false, true).unwrap(); + let opts: mysql::Opts = opts.into(); + assert!( + opts.get_additional_capabilities() + .contains(CapabilityFlags::CLIENT_IGNORE_SPACE) + ); + } + + /// Multi-statement detection ignores semicolons inside every quoted/comment + /// region and accepts one trailing separator, while finding real second SQL. + #[test] + fn multi_statement_detection_is_sql_aware() { + assert!(!sql_has_multiple_statements("SELECT ';';", false)); + assert!(!sql_has_multiple_statements( + "SELECT 1 /* ; SELECT 2 */; -- tail ;\n", + false, + )); + assert!(sql_has_multiple_statements("SELECT 1; SELECT 2", false)); + assert!(sql_has_multiple_statements("SELECT `a;b`; CALL p()", false)); + } + + /// P2-3: a `charset=` DSN key is captured (for `MyConn::open` to turn + /// into a `SET NAMES ` init statement), validated to plain identifier + /// characters so it cannot inject SQL into that generated statement. + #[test] + fn build_opts_captures_valid_charset() { + let (_opts, charset) = + build_opts( + "mysql:host=localhost;dbname=testdb;charset=utf8mb4", + false, + false, + ) + .unwrap(); + assert_eq!(charset.as_deref(), Some("utf8mb4")); + } + + /// A `charset` value containing anything beyond `[A-Za-z0-9_]` (e.g. an + /// attempted SQL-injection payload embedded in the DSN's `charset=` value — + /// a `;` in the payload would already be defused by the DSN's own + /// semicolon-segmented parsing, so this uses a quote/space payload that + /// stays within the one `charset=` segment) is dropped rather than reaching + /// the generated `SET NAMES` statement. + #[test] + fn build_opts_rejects_charset_with_unsafe_characters() { + let (_opts, charset) = build_opts( + "mysql:host=localhost;dbname=testdb;charset=utf8mb4' OR '1'='1", + false, + false, + ) + .unwrap(); + assert_eq!(charset, None); + } + + /// A DSN with no `charset` key leaves it unset. + #[test] + fn build_opts_leaves_charset_unset_by_default() { + let (_opts, charset) = + build_opts("mysql:host=localhost;dbname=testdb", false, false).unwrap(); + assert_eq!(charset, None); + } + + /// F-CORE-02: the prelude percent-encodes a constructor-supplied password + /// containing ';' and '%' (here `a;b%c` -> `a%3Bb%25c`) before folding it + /// into the DSN, so it survives `body.split(';')` intact instead of + /// truncating at the embedded ';'. `build_opts` must undo that encoding — + /// `%3B` back to ';', `%25` back to '%' — landing on the original value. + #[test] + fn build_opts_percent_decodes_a_password_containing_semicolon_and_percent() { + let (opts, _charset) = build_opts( + "mysql:host=127.0.0.1;user=admin;password=a%3Bb%25c", + false, + false, + ) + .unwrap(); + let opts: mysql::Opts = opts.into(); + assert_eq!(opts.get_user(), Some("admin")); + assert_eq!(opts.get_pass(), Some("a;b%c")); + } + + /// The percent-decoding above must be a no-op for a credential with no '%' + /// byte at all — the common case — so it round-trips byte-identical. + #[test] + fn build_opts_leaves_a_plain_password_byte_identical() { + let (opts, _charset) = + build_opts( + "mysql:host=127.0.0.1;user=admin;password=secret", + false, + false, + ) + .unwrap(); + let opts: mysql::Opts = opts.into(); + assert_eq!(opts.get_pass(), Some("secret")); + } + + /// An empty SSL config is always a no-op (plaintext), regardless of feature. + #[test] + fn apply_ssl_opts_empty_is_noop() { + assert!(apply_ssl_opts(OptsBuilder::new(), "", None).is_ok()); + } + + /// In a custom build without `mysql-tls`, a non-empty SSL config fails loudly + /// so a program that asked for TLS is not silently downgraded to plaintext. + #[cfg(not(feature = "mysql-tls"))] + #[test] + fn apply_ssl_opts_requires_feature_when_configured() { + // `OptsBuilder` has no `Debug`, so match rather than `unwrap_err`. + match apply_ssl_opts(OptsBuilder::new(), "ca=/etc/ca.pem", None) { + Ok(_) => panic!("expected an error when the mysql-tls feature is disabled"), + Err(err) => assert!(err.contains("mysql-tls"), "unexpected error: {err}"), + } + } + + /// With `mysql-tls`, a packed config is parsed into `SslOpts` and attached + /// without panicking (the ring provider installs on demand). + #[cfg(feature = "mysql-tls")] + #[test] + fn apply_ssl_opts_builds_sslopts() { + assert!(apply_ssl_opts( + OptsBuilder::new(), + "ca=/etc/ca.pem;verify=0", + Some("ECDHE-RSA-AES128-GCM-SHA256") + ) + .is_ok()); + } + + /// F-MY-08 (mandatory unit test, no server needed): the wire-type -> `native_type` + /// mapping backing `getColumnMeta()`'s MySQL branch is php-src's `type_to_name_native` + /// (`ext/pdo_mysql/mysql_statement.c:716-770`), whose `PDO_MYSQL_NATIVE_TYPE_NAME(x)` + /// macro STRINGIFIES THE `MYSQL_TYPE_` SUFFIX. The names therefore do NOT match the SQL + /// keyword a user wrote, and that is the whole point of pinning them: an `INT` column + /// reports `LONG`, a `TINYINT` reports `TINY`, a `BIGINT` reports `LONGLONG`, a + /// `MEDIUMINT` reports `INT24`, a `VARCHAR` reports `VAR_STRING`, a `CHAR` reports + /// `STRING`, and a modern `DECIMAL` reports `NEWDECIMAL` (plain `DECIMAL` is only the + /// pre-5.0 legacy type). A "helpful" mapping to the SQL spelling would be a divergence + /// from real PDO dressed up as a courtesy. + #[test] + fn native_type_name_matches_php_src_type_to_name_native() { + // The counter-intuitive ones — where the wire name and the SQL keyword differ. + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_LONG), "LONG"); // INT + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_TINY), "TINY"); // TINYINT + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_LONGLONG), "LONGLONG"); // BIGINT + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_INT24), "INT24"); // MEDIUMINT + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_VAR_STRING), "VAR_STRING"); // VARCHAR + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_STRING), "STRING"); // CHAR + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_NEWDECIMAL), "NEWDECIMAL"); // DECIMAL + + // The ones that do read as expected, spot-checked across the type families. + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_SHORT), "SHORT"); + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_BIT), "BIT"); + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_BLOB), "BLOB"); + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_JSON), "JSON"); + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_DATETIME), "DATETIME"); + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_NULL), "NULL"); + } + + /// F-MY-08, the `default:` arm: php-src's switch has no case for a server-INTERNAL type + /// and `return NULL`s, which makes `pdo_mysql_stmt_col_meta` OMIT the `native_type` key + /// entirely (`mysql_statement.c:812-815`). The bridge's neutral stand-in for that is the + /// EMPTY STRING, and the empty string is load-bearing downstream: the prelude's + /// `getColumnMeta()` only OVERRIDES its derived storage-class `native_type` when this + /// returns something non-empty, so `""` is exactly what keeps SQLite's metadata (where + /// this is never called with a real MySQL type) byte-identical. + /// + /// `MYSQL_TYPE_TIMESTAMP2`/`DATETIME2`/`TIME2` are the internal ones worth naming: the + /// wire carries the plain `TIMESTAMP`/`DATETIME`/`TIME` codes, so these must never reach + /// a real column — and if one somehow does, omitting the key beats inventing a name. + #[test] + fn native_type_name_is_empty_for_types_php_src_has_no_case_for() { + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_TIMESTAMP2), ""); + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_DATETIME2), ""); + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_TIME2), ""); + assert_eq!(native_type_name(ColumnType::MYSQL_TYPE_UNKNOWN), ""); + } +} diff --git a/crates/elephc-pdo/src/oci.rs b/crates/elephc-pdo/src/oci.rs new file mode 100644 index 0000000000..6a8cb58ff8 --- /dev/null +++ b/crates/elephc-pdo/src/oci.rs @@ -0,0 +1,1115 @@ +//! Purpose: +//! Optional PDO_OCI backend implemented through Oracle Instant Client and ODPI-C. +//! +//! Called from: +//! - `crate` connection/statement dispatch when the `oci` Cargo feature is selected. +//! +//! Key details: +//! - DSN, autocommit, prefetch, diagnostics, metadata, and session attributes mirror PDO_OCI 1.2. +//! - Oracle scalar results remain strings; LOB results are tagged for PHP stream materialization. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::time::Duration; + +use oracle::sql_type::{Blob, OracleType, ToSql}; +use oracle::{Connection, Error, Statement, StatementType, Version}; + +const ATTR_AUTOCOMMIT: i64 = 0; +const ATTR_PREFETCH: i64 = 1; +const OCI_ATTR_ACTION: i64 = 1000; +const OCI_ATTR_CLIENT_INFO: i64 = 1001; +const OCI_ATTR_CLIENT_IDENTIFIER: i64 = 1002; +const OCI_ATTR_MODULE: i64 = 1003; +const OCI_ATTR_CALL_TIMEOUT: i64 = 1004; +const DEFAULT_PREFETCH: u32 = 100; + +/// One PDO-compatible Oracle error snapshot. +#[derive(Clone, Debug)] +struct ErrorState { + sqlstate: String, + native_code: i64, + message: String, +} + +impl Default for ErrorState { + /// Returns PDO's no-error state. + fn default() -> Self { + Self { + sqlstate: "00000".to_string(), + native_code: 0, + message: String::new(), + } + } +} + +impl ErrorState { + /// Converts an Oracle/ODPI diagnostic into PDO_OCI's SQLSTATE mapping. + fn from_oracle(operation: &str, error: &Error) -> Self { + let native_code = error.db_error().map_or(0, |error| i64::from(error.code())); + Self { + sqlstate: sqlstate_for_code(native_code).to_string(), + native_code, + message: format!("{operation}: {error}"), + } + } +} + +/// Recovers PDO_OCI's SQLSTATE and native ORA code from a failed-open message. +pub(crate) fn open_diagnostic(message: &str) -> (&'static str, i64) { + let native_code = message + .match_indices("ORA-") + .find_map(|(index, _)| { + message + .get(index + 4..index + 9) + .and_then(|digits| digits.parse::().ok()) + }) + .unwrap_or(0); + (sqlstate_for_code(native_code), native_code) +} + +/// Maps Oracle native diagnostics through PDO_OCI's maintained SQLSTATE table. +fn sqlstate_for_code(native_code: i64) -> &'static str { + match native_code { + 12154 => "42S02", + 22 | 378 | 602 | 603 | 604 | 609 | 1012 | 1033 | 1041 | 1043 | 1089 | 1090 + | 1092 | 3113 | 3114 | 3122 | 3135 | 12153 | 27146 | 28511 => "01002", + _ => "HY000", + } +} + +/// Parsed PDO_OCI connection settings. +#[derive(Debug, Eq, PartialEq)] +struct OpenOptions { + dbname: String, + username: String, + password: String, + charset: Option, + auto_commit: bool, +} + +/// Percent-decodes constructor credentials serialized by the PHP prelude. +fn decode_credential(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = &value[index + 1..index + 3]; + if let Ok(byte) = u8::from_str_radix(hex, 16) { + decoded.push(byte); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&decoded).into_owned() +} + +/// Parses php-src's `oci:dbname=...;charset=...` DSN and bridge-only fields. +fn parse_dsn(dsn: &str) -> Result { + let body = dsn + .strip_prefix("oci:") + .ok_or_else(|| "could not find driver".to_string())?; + let mut options = OpenOptions { + dbname: String::new(), + username: String::new(), + password: String::new(), + charset: None, + auto_commit: true, + }; + for field in body.split(';').filter(|field| !field.is_empty()) { + let Some((key, value)) = field.split_once('=') else { + continue; + }; + match key.to_ascii_lowercase().as_str() { + "dbname" => options.dbname = value.to_string(), + "charset" => options.charset = Some(value.to_string()), + "user" => options.username = decode_credential(value), + "password" => options.password = decode_credential(value), + "elephc_oci_autocommit" => options.auto_commit = value != "0", + // Common constructor plumbing appends these for other network drivers. + "connect_timeout" | "elephc_odbc_cursor_library" | "elephc_odbc_assume_utf8" + | "elephc_odbc_autocommit" => {} + _ => {} + } + } + if let Some(charset) = options.charset.as_deref() { + if !matches!(charset.to_ascii_uppercase().as_str(), "AL32UTF8" | "UTF8") { + return Err(format!( + "OCIEnvNlsCreate: character set {charset} is unavailable through the UTF-8 ODPI-C environment" + )); + } + } + Ok(options) +} + +/// Live Oracle connection and PDO-visible state. +pub struct OciConn { + connection: Connection, + error: ErrorState, + pub changes: i64, + pub in_transaction: bool, + auto_commit: bool, + prefetch: u32, +} + +impl OciConn { + /// Opens an Oracle session using PDO_OCI DSN and credential precedence. + pub fn open(dsn: &str) -> Result { + let options = parse_dsn(dsn)?; + let mut connection = Connection::connect( + &options.username, + &options.password, + &options.dbname, + ) + .map_err(|error| ErrorState::from_oracle("pdo_oci_handle_factory", &error).message)?; + connection.set_autocommit(options.auto_commit); + Ok(Self { + connection, + error: ErrorState::default(), + changes: 0, + in_transaction: false, + auto_commit: options.auto_commit, + prefetch: DEFAULT_PREFETCH, + }) + } + + /// Performs PDO_OCI's OCIPing-equivalent persistent-connection probe. + pub fn is_alive(&mut self) -> bool { + match self.connection.ping() { + Ok(()) => true, + Err(error) if error.db_error().is_some_and(|error| error.code() == 1010) => true, + Err(error) => { + self.error = ErrorState::from_oracle("OCIPing", &error); + false + } + } + } + + /// Executes a non-SELECT statement and returns its affected-row count. + pub fn exec(&mut self, sql: &str) -> i64 { + match self.connection.execute(sql, &[]) { + Ok(statement) => { + self.changes = statement.row_count().unwrap_or(0) as i64; + self.error = ErrorState::default(); + self.changes + } + Err(error) => { + self.error = ErrorState::from_oracle("OCIStmtExecute", &error); + self.changes = 0; + -1 + } + } + } + + /// Starts PDO's tracked Oracle transaction without issuing SQL. + pub fn begin(&mut self) -> bool { + if self.in_transaction { + return false; + } + self.connection.set_autocommit(false); + self.in_transaction = true; + true + } + + /// Commits the active Oracle transaction and restores configured autocommit. + pub fn commit(&mut self) -> bool { + match self.connection.commit() { + Ok(()) => { + self.in_transaction = false; + self.connection.set_autocommit(self.auto_commit); + self.error = ErrorState::default(); + true + } + Err(error) => { + self.error = ErrorState::from_oracle("OCITransCommit", &error); + false + } + } + } + + /// Rolls back the active Oracle transaction and restores configured autocommit. + pub fn rollback(&mut self) -> bool { + match self.connection.rollback() { + Ok(()) => { + self.in_transaction = false; + self.connection.set_autocommit(self.auto_commit); + self.error = ErrorState::default(); + true + } + Err(error) => { + self.error = ErrorState::from_oracle("OCITransRollback", &error); + false + } + } + } + + /// Applies an integer-valued PDO_OCI connection attribute. + pub fn set_attribute_int(&mut self, attribute: i64, value: i64) -> bool { + match attribute { + ATTR_AUTOCOMMIT => { + if self.in_transaction && !self.commit() { + return false; + } + self.auto_commit = value != 0; + self.connection.set_autocommit(self.auto_commit); + true + } + ATTR_PREFETCH => { + self.prefetch = sanitize_prefetch(value); + true + } + OCI_ATTR_CALL_TIMEOUT => { + let timeout = u64::from(value as u32); + match self.connection.set_call_timeout(Some(Duration::from_millis(timeout))) { + Ok(()) => true, + Err(error) => { + self.error = ErrorState::from_oracle("OCIAttrSet: OCI_ATTR_CALL_TIMEOUT", &error); + false + } + } + } + _ => false, + } + } + + /// Applies one string-valued PDO_OCI session attribute. + pub fn set_attribute_text(&mut self, attribute: i64, value: &str) -> bool { + let result = match attribute { + OCI_ATTR_ACTION => self.connection.set_action(value), + OCI_ATTR_CLIENT_INFO => self.connection.set_client_info(value), + OCI_ATTR_CLIENT_IDENTIFIER => self.connection.set_client_identifier(value), + OCI_ATTR_MODULE => self.connection.set_module(value), + _ => return false, + }; + match result { + Ok(()) => true, + Err(error) => { + self.error = ErrorState::from_oracle("OCIAttrSet", &error); + false + } + } + } + + /// Reads an integer-valued PDO_OCI connection attribute. + pub fn attribute_int(&mut self, attribute: i64) -> Option { + match attribute { + ATTR_AUTOCOMMIT => Some(self.auto_commit as i64), + ATTR_PREFETCH => Some(i64::from(self.prefetch)), + OCI_ATTR_CALL_TIMEOUT => match self.connection.call_timeout() { + Ok(timeout) => Some(timeout.map_or(0, |timeout| timeout.as_millis() as i64)), + Err(error) => { + self.error = ErrorState::from_oracle("OCIAttrGet: OCI_ATTR_CALL_TIMEOUT", &error); + None + } + }, + _ => None, + } + } + + /// Returns the Oracle server version tuple. + pub fn server_version(&mut self) -> String { + match self.connection.server_version() { + Ok((version, _)) => version.to_string(), + Err(error) => { + self.error = ErrorState::from_oracle("OCIServerRelease", &error); + "<>".to_string() + } + } + } + + /// Returns Oracle's server release banner. + pub fn server_info(&mut self) -> String { + match self.connection.server_version() { + Ok((_, banner)) => banner, + Err(error) => { + self.error = ErrorState::from_oracle("OCIServerRelease", &error); + "<>".to_string() + } + } + } + + /// Returns the loaded Oracle Instant Client version. + pub fn client_version(&self) -> String { + Version::client().map_or_else(|_| String::new(), |version| version.to_string()) + } + + /// Returns the configured prepare-time prefetch row count. + pub fn prefetch(&self) -> u32 { + self.prefetch + } + + /// Returns the current connection SQLSTATE. + pub fn sqlstate(&self) -> &str { + &self.error.sqlstate + } + + /// Returns the current native Oracle code. + pub fn errcode(&self) -> i64 { + self.error.native_code + } + + /// Returns the current Oracle diagnostic text. + pub fn errmsg(&self) -> &str { + &self.error.message + } +} + +/// One bound PDO_OCI value; non-LOB values are intentionally sent as strings. +#[derive(Clone)] +enum OciBind { + Unbound, + Null, + Text(String), + Blob(Vec), +} + +/// Metadata retained for one Oracle result column. +#[derive(Clone)] +struct OciColumn { + name: String, + oracle_type: OracleType, + nullable: bool, +} + +/// One materialized Oracle result cell. +#[derive(Clone)] +pub(crate) struct OciCell { + pub(crate) data: Option>, + pub(crate) lob: bool, +} + +/// One PDO_OCI output-bind declaration retained until native execution. +#[derive(Clone)] +struct OciOutputSpec { + lob: bool, + max_length: u32, +} + +/// Prepared PDO_OCI statement with buffered result rows. +pub struct OciStmt { + pub conn_id: i64, + native_sql: String, + named_map: HashMap, + binds: Vec, + output_specs: Vec>, + output_values: Vec>, + columns: Vec, + rows: Vec>, + cursor: isize, + executed: bool, + row_count: i64, + prefetch: u32, + pub sent_sql: String, + error: ErrorState, +} + +impl OciStmt { + /// Validates and records a native Oracle statement and PDO placeholders. + pub fn new(connection: &mut OciConn, conn_id: i64, sql: &str) -> Result { + let (translated, named_map, order, mixed) = crate::my::translate_placeholders(sql, false); + if mixed { + return Err("Invalid parameter number: mixed named and positional parameters".to_string()); + } + let native_sql = translate_oracle_placeholders(&translated, &order)?; + if let Err(error) = connection.connection.statement(&native_sql).build() { + connection.error = ErrorState::from_oracle("OCIStmtPrepare", &error); + return Err(connection.error.message.clone()); + } + let slots = order.iter().copied().max().unwrap_or(0).max(0) as usize; + Ok(Self { + conn_id, + native_sql, + named_map, + binds: vec![OciBind::Unbound; slots], + output_specs: vec![None; slots], + output_values: vec![None; slots], + columns: Vec::new(), + rows: Vec::new(), + cursor: -1, + executed: false, + row_count: 0, + prefetch: connection.prefetch(), + sent_sql: String::new(), + error: ErrorState::default(), + }) + } + + /// Resolves a named placeholder to its one-based PDO slot. + pub fn parameter_index(&self, name: &str) -> i64 { + self.named_map.get(name.trim_start_matches(':')).copied().unwrap_or(-1) + } + + /// Stores one value in a one-based PDO bind slot. + fn bind(&mut self, index: i64, value: OciBind) -> bool { + let Some(slot) = usize::try_from(index).ok().and_then(|index| index.checked_sub(1)) else { + return false; + }; + let Some(target) = self.binds.get_mut(slot) else { + return false; + }; + *target = value; + true + } + + /// Binds an integer using PDO_OCI's string conversion. + pub fn bind_int(&mut self, index: i64, value: i64) -> bool { + self.bind(index, OciBind::Text(value.to_string())) + } + + /// Binds a floating-point value using PDO_OCI's string conversion. + pub fn bind_double(&mut self, index: i64, value: f64) -> bool { + self.bind(index, OciBind::Text(value.to_string())) + } + + /// Binds text bytes using the compiled PHP string encoding. + pub fn bind_text(&mut self, index: i64, value: Vec) -> bool { + self.bind(index, OciBind::Text(String::from_utf8_lossy(&value).into_owned())) + } + + /// Binds a temporary Oracle BLOB. + pub fn bind_blob(&mut self, index: i64, value: Vec) -> bool { + self.bind(index, OciBind::Blob(value)) + } + + /// Binds SQL NULL. + pub fn bind_null(&mut self, index: i64) -> bool { + self.bind(index, OciBind::Null) + } + + /// Marks one bind as an OCI input/output value with PDO's buffer-size rules. + pub fn bind_output(&mut self, index: i64, pdo_type: i64, max_length: i64) -> bool { + let Some(slot) = usize::try_from(index).ok().and_then(|index| index.checked_sub(1)) else { + return false; + }; + let Some(target) = self.output_specs.get_mut(slot) else { + return false; + }; + let max_length = if max_length <= 0 { + 1332 + } else { + u32::try_from(max_length).unwrap_or(u32::MAX) + }; + *target = Some(OciOutputSpec { + lob: (pdo_type & 0xFFFF) == 3, + max_length, + }); + true + } + + /// Overrides the statement prefetch row count before execution. + pub fn set_prefetch(&mut self, value: i64) -> i64 { + if self.executed { + return 0; + } + self.prefetch = sanitize_prefetch(value); + 1 + } + + /// Resets result/cursor state while preserving bound parameters. + pub fn reset(&mut self) { + self.columns.clear(); + self.rows.clear(); + self.cursor = -1; + self.executed = false; + self.row_count = 0; + self.output_values.fill(None); + } + + /// Clears result state and all bound values. + pub fn clear_bindings(&mut self) { + self.reset(); + self.binds.fill(OciBind::Unbound); + self.output_specs.fill(None); + } + + /// Reports whether the statement still needs native execution. + pub fn needs_execute(&self) -> bool { + !self.executed + } + + /// Executes and fully materializes one Oracle result set. + pub fn execute(&mut self, connection: &mut OciConn) -> Result<(), String> { + if self.binds.iter().any(|value| matches!(value, OciBind::Unbound)) { + self.error = ErrorState { + sqlstate: "HY093".to_string(), + native_code: 0, + message: "Invalid parameter number: number of bound variables does not match number of tokens".to_string(), + }; + return Err(self.error.message.clone()); + } + let mut builder = connection.connection.statement(&self.native_sql); + builder.prefetch_rows(self.prefetch); + let mut statement = builder.build().map_err(|error| { + self.error = ErrorState::from_oracle("OCIStmtPrepare", &error); + self.error.message.clone() + })?; + bind_statement_values( + &connection.connection, + &mut statement, + &self.binds, + &self.output_specs, + ) + .map_err(|message| { + self.error = ErrorState { + sqlstate: "HY000".to_string(), + native_code: 0, + message: format!("OCIBind: {message}"), + }; + self.error.message.clone() + })?; + self.columns.clear(); + self.rows.clear(); + self.cursor = -1; + let result = if statement.statement_type() == StatementType::Select { + self.materialize_query(&mut statement, &[]) + } else { + statement.execute(&[]).map(|()| { + self.row_count = statement.row_count().unwrap_or(0) as i64; + }) + }; + if let Err(error) = result { + self.error = ErrorState::from_oracle("OCIStmtExecute", &error); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + if let Err(message) = self.capture_output_values(&statement) { + self.error = ErrorState { + sqlstate: "HY000".to_string(), + native_code: 0, + message: format!("OCIBind output: {message}"), + }; + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + connection.changes = self.row_count; + connection.error = ErrorState::default(); + self.error = ErrorState::default(); + self.sent_sql.clear(); + self.executed = true; + Ok(()) + } + + /// Copies native OCI output buffers into bridge-owned scalar or LOB bytes. + fn capture_output_values(&mut self, statement: &Statement) -> Result<(), String> { + let returning = matches!( + statement.statement_type(), + StatementType::Insert + | StatementType::Update + | StatementType::Delete + | StatementType::Merge + ); + for (slot, spec) in self.output_specs.iter().enumerate() { + let Some(spec) = spec else { + continue; + }; + let index = slot + 1; + let data = if spec.lob { + read_output_blob(statement, index, returning)? + } else { + read_output_text(statement, index, returning)?.map(String::into_bytes) + }; + self.output_values[slot] = Some(OciCell { + data, + lob: spec.lob, + }); + } + Ok(()) + } + + /// Returns one completed output bind for PHP-side reference synchronization. + pub(crate) fn output_value(&self, index: i64) -> Option<&OciCell> { + usize::try_from(index) + .ok() + .and_then(|index| index.checked_sub(1)) + .and_then(|slot| self.output_values.get(slot)) + .and_then(Option::as_ref) + } + + /// Buffers the query rows and php-src-compatible Oracle metadata. + fn materialize_query( + &mut self, + statement: &mut Statement, + parameters: &[&dyn ToSql], + ) -> oracle::Result<()> { + let rows = statement.query(parameters)?; + self.columns = rows + .column_info() + .iter() + .map(|column| OciColumn { + name: column.name().to_string(), + oracle_type: column.oracle_type().clone(), + nullable: column.nullable(), + }) + .collect(); + for result in rows { + let row = result?; + let mut output = Vec::with_capacity(self.columns.len()); + for (index, column) in self.columns.iter().enumerate() { + let lob = matches!( + column.oracle_type, + OracleType::BLOB | OracleType::CLOB | OracleType::NCLOB | OracleType::BFILE + ); + let data = match column.oracle_type { + OracleType::BLOB | OracleType::BFILE => row.get::<_, Option>>(index)?, + OracleType::Raw(_) | OracleType::LongRaw => row + .get::<_, Option>>(index)? + .map(|value| uppercase_hex(&value).into_bytes()), + _ => row.get::<_, Option>(index)?.map(String::into_bytes), + }; + output.push(OciCell { data, lob }); + } + self.rows.push(output); + } + // PDO_OCI snapshots OCI_ATTR_ROW_COUNT at execute time, before any SELECT + // fetch, so SELECT statements keep rowCount() at zero. + self.row_count = 0; + Ok(()) + } + + /// Advances to the next buffered result row. + pub fn step(&mut self) -> i64 { + let next = self.cursor + 1; + if next < self.rows.len() as isize { + self.cursor = next; + 1 + } else { + 0 + } + } + + /// Selects a buffered row with PDO's scroll-orientation semantics. + pub fn step_oriented(&mut self, orientation: i64, offset: i64) -> i64 { + let target = match orientation { + 0 => self.cursor + 1, + 1 => self.cursor - 1, + 2 => 0, + 3 => self.rows.len() as isize - 1, + 4 => offset as isize, + 5 => self.cursor + offset as isize, + _ => return 0, + }; + if target < 0 || target >= self.rows.len() as isize { + return 0; + } + self.cursor = target; + 1 + } + + /// Returns the active Oracle column count. + pub fn column_count(&self) -> i64 { + self.columns.len() as i64 + } + + /// Returns one Oracle column name. + pub fn column_name(&self, index: i64) -> String { + usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .map_or_else(String::new, |column| column.name.clone()) + } + + /// Returns the bridge cell tag: string, LOB stream, or NULL. + pub fn column_type(&self, index: i64) -> i64 { + match self.cell(index) { + Some(OciCell { data: None, .. }) | None => 5, + Some(OciCell { lob: true, .. }) => 4, + Some(_) => 3, + } + } + + /// Parses the current Oracle string cell as integer. + pub fn column_int(&self, index: i64) -> i64 { + String::from_utf8_lossy(&self.column_data(index)).parse().unwrap_or(0) + } + + /// Parses the current Oracle string cell as floating point. + pub fn column_double(&self, index: i64) -> f64 { + String::from_utf8_lossy(&self.column_data(index)).parse().unwrap_or(0.0) + } + + /// Returns the exact bytes for the current Oracle cell. + pub fn column_data(&self, index: i64) -> Vec { + self.cell(index).and_then(|cell| cell.data.clone()).unwrap_or_default() + } + + /// Returns php-src's OCI declared/native type name. + pub fn column_native_type(&self, index: i64) -> String { + self.column(index).map_or_else(String::new, |column| oracle_type_name(&column.oracle_type)) + } + + /// Returns PDO_PARAM_LOB for Oracle LOB columns and PDO_PARAM_STR otherwise. + pub fn column_pdo_type(&self, index: i64) -> i64 { + self.column(index).map_or(2, |column| { + if matches!( + column.oracle_type, + OracleType::BLOB | OracleType::CLOB | OracleType::NCLOB | OracleType::BFILE + ) { + 3 + } else { + 2 + } + }) + } + + /// Returns the declared numeric scale or zero for non-NUMBER columns. + pub fn column_scale(&self, index: i64) -> i64 { + self.column(index).map_or(0, |column| match column.oracle_type { + OracleType::Number(_, scale) => i64::from(scale), + _ => 0, + }) + } + + /// Returns nullable/blob metadata flags as bridge bits. + pub fn column_flags(&self, index: i64) -> i64 { + self.column(index).map_or(0, |column| { + let mut flags = if column.nullable { 1 } else { 2 }; + if matches!( + column.oracle_type, + OracleType::BLOB | OracleType::CLOB | OracleType::NCLOB | OracleType::BFILE + ) { + flags |= 4; + } + flags + }) + } + + /// Returns one stored Oracle column. + fn column(&self, index: i64) -> Option<&OciColumn> { + usize::try_from(index).ok().and_then(|index| self.columns.get(index)) + } + + /// Returns one current Oracle cell. + fn cell(&self, index: i64) -> Option<&OciCell> { + let row = usize::try_from(self.cursor).ok().and_then(|row| self.rows.get(row))?; + usize::try_from(index).ok().and_then(|index| row.get(index)) + } + + /// Returns the statement SQLSTATE. + pub fn sqlstate(&self) -> &str { + &self.error.sqlstate + } + + /// Returns the statement native Oracle code. + pub fn errcode(&self) -> i64 { + self.error.native_code + } + + /// Returns the statement Oracle diagnostic text. + pub fn errmsg(&self) -> &str { + &self.error.message + } +} + +/// Rewrites normalized question-mark placeholders into repeatable Oracle names. +fn translate_oracle_placeholders(sql: &str, order: &[i64]) -> Result { + let mut translated = String::with_capacity(sql.len() + order.len() * 16); + let mut occurrence = 0usize; + for ch in sql.chars() { + if ch == '?' { + let Some(slot) = order.get(occurrence) else { + return Err("Invalid parameter number".to_string()); + }; + translated.push_str(":elephc_pdo_"); + translated.push_str(&slot.to_string()); + occurrence += 1; + } else { + translated.push(ch); + } + } + if occurrence != order.len() { + return Err("Invalid parameter number".to_string()); + } + Ok(translated) +} + +/// Applies PDO_OCI's negative/overflow prefetch sanitization. +fn sanitize_prefetch(value: i64) -> u32 { + if value < 0 { + 0 + } else if value > i64::from(u32::MAX / 1024) { + DEFAULT_PREFETCH + } else { + value as u32 + } +} + +/// Copies each retained PDO value into rust-oracle's native statement buffers. +fn bind_statement_values( + connection: &Connection, + statement: &mut Statement, + binds: &[OciBind], + output_specs: &[Option], +) -> Result<(), String> { + for (slot, bind) in binds.iter().enumerate() { + let index = slot + 1; + match (bind, output_specs.get(slot).and_then(Option::as_ref)) { + (OciBind::Unbound | OciBind::Null, Some(spec)) if spec.lob => statement + .bind(index, &OracleType::BLOB) + .map_err(|error| error.to_string())?, + (OciBind::Blob(value), Some(spec)) if spec.lob => { + let mut blob = Blob::new(connection).map_err(|error| error.to_string())?; + blob.write_all(value).map_err(|error| error.to_string())?; + statement + .bind(index, &(&blob, &OracleType::BLOB)) + .map_err(|error| error.to_string())?; + } + (OciBind::Text(value), Some(spec)) if spec.lob => { + let mut blob = Blob::new(connection).map_err(|error| error.to_string())?; + blob.write_all(value.as_bytes()).map_err(|error| error.to_string())?; + statement + .bind(index, &(&blob, &OracleType::BLOB)) + .map_err(|error| error.to_string())?; + } + (OciBind::Unbound | OciBind::Null, Some(spec)) => statement + .bind(index, &OracleType::Varchar2(spec.max_length)) + .map_err(|error| error.to_string())?, + (OciBind::Text(value), Some(spec)) => statement + .bind(index, &(value, &OracleType::Varchar2(spec.max_length))) + .map_err(|error| error.to_string())?, + (OciBind::Blob(value), Some(spec)) => { + let value = String::from_utf8_lossy(value).into_owned(); + statement + .bind(index, &(&value, &OracleType::Varchar2(spec.max_length))) + .map_err(|error| error.to_string())?; + } + (OciBind::Unbound | OciBind::Null, None) => statement + .bind(index, &Option::::None) + .map_err(|error| error.to_string())?, + (OciBind::Text(value), None) => statement + .bind(index, value) + .map_err(|error| error.to_string())?, + (OciBind::Blob(value), None) => { + let mut blob = Blob::new(connection).map_err(|error| error.to_string())?; + blob.write_all(value).map_err(|error| error.to_string())?; + statement.bind(index, &blob).map_err(|error| error.to_string())?; + } + } + } + Ok(()) +} + +/// Reads one scalar OCI output bind, including DML `RETURNING INTO` arrays. +fn read_output_text( + statement: &Statement, + index: usize, + returning: bool, +) -> Result, String> { + if returning { + let values: Vec> = statement + .returned_values(index) + .map_err(|error| error.to_string())?; + if let Some(value) = values.into_iter().last() { + return Ok(value); + } + } + statement.bind_value(index).map_err(|error| error.to_string()) +} + +/// Reads one OCI LOB output bind and drains the locator into owned bytes. +fn read_output_blob( + statement: &Statement, + index: usize, + returning: bool, +) -> Result>, String> { + let mut blob = if returning { + let values: Vec> = statement + .returned_values(index) + .map_err(|error| error.to_string())?; + values.into_iter().last().flatten() + } else { + statement.bind_value(index).map_err(|error| error.to_string())? + }; + let Some(blob) = blob.as_mut() else { + return Ok(None); + }; + let mut bytes = Vec::new(); + blob.read_to_end(&mut bytes).map_err(|error| error.to_string())?; + Ok(Some(bytes)) +} + +/// Maps rust-oracle's precise type enum to PDO_OCI's metadata spelling. +fn oracle_type_name(oracle_type: &OracleType) -> String { + match oracle_type { + OracleType::Timestamp(_) => "TIMESTAMP", + OracleType::TimestampTZ(_) => "TIMESTAMP WITH TIMEZONE", + OracleType::TimestampLTZ(_) => "TIMESTAMP WITH LOCAL TIMEZONE", + OracleType::IntervalYM(_) => "INTERVAL YEAR TO MONTH", + OracleType::IntervalDS(_, _) => "INTERVAL DAY TO SECOND", + OracleType::Date => "DATE", + OracleType::Float(_) => "FLOAT", + OracleType::Number(_, _) | OracleType::Int64 => "NUMBER", + OracleType::Long => "LONG", + OracleType::Raw(_) => "RAW", + OracleType::LongRaw => "LONG RAW", + OracleType::NVarchar2(_) => "NVARCHAR2", + OracleType::NChar(_) => "NCHAR", + OracleType::Varchar2(_) => "VARCHAR2", + OracleType::Char(_) => "CHAR", + OracleType::BLOB => "BLOB", + OracleType::NCLOB => "NCLOB", + OracleType::CLOB => "CLOB", + OracleType::BFILE => "BFILE", + OracleType::Rowid => "ROWID", + OracleType::BinaryFloat => "BINARY_FLOAT", + OracleType::BinaryDouble => "BINARY_DOUBLE", + OracleType::Json => "JSON", + OracleType::Xml => "XML", + _ => "UNKNOWN", + } + .to_string() +} + +/// Renders Oracle RAW values through the SQLT_CHR hexadecimal conversion PDO_OCI uses. +fn uppercase_hex(value: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut output = String::with_capacity(value.len() * 2); + for byte in value { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Parses OCI DSN credentials, charset, and constructor autocommit state. + #[test] + fn parses_oci_dsn() { + let options = parse_dsn( + "oci:dbname=//db.example:1521/app;charset=AL32UTF8;user=scott%3Badmin;password=t%25iger;elephc_oci_autocommit=0", + ) + .unwrap(); + assert_eq!(options.dbname, "//db.example:1521/app"); + assert_eq!(options.username, "scott;admin"); + assert_eq!(options.password, "t%iger"); + assert_eq!(options.charset.as_deref(), Some("AL32UTF8")); + assert!(!options.auto_commit); + } + + /// Reuses one Oracle bind name for repeated named placeholders. + #[test] + fn translates_repeated_oracle_placeholders() { + let sql = translate_oracle_placeholders("select ? + ? + ? from dual", &[1, 2, 1]).unwrap(); + assert_eq!( + sql, + "select :elephc_pdo_1 + :elephc_pdo_2 + :elephc_pdo_1 from dual" + ); + } + + /// Rejects character sets ODPI-C cannot expose through its UTF-8 environment. + #[test] + fn rejects_non_utf8_charset() { + assert!(parse_dsn("oci:dbname=db;charset=WE8MSWIN1252").is_err()); + } + + /// Recovers php-src's special OCI SQLSTATE mappings from open diagnostics. + #[test] + fn maps_open_diagnostics() { + assert_eq!(open_diagnostic("ORA-12154: TNS could not resolve"), ("42S02", 12154)); + assert_eq!(open_diagnostic("ORA-03113: end-of-file"), ("01002", 3113)); + assert_eq!(open_diagnostic("DPI-1047: client library missing"), ("HY000", 0)); + } + + /// Matches PDO_OCI's uppercase SQLT_CHR rendering for RAW columns. + #[test] + fn renders_raw_values_as_uppercase_hex() { + assert_eq!(uppercase_hex(&[0, 0xab, 0xff]), "00ABFF"); + } + + /// Mirrors PDO_OCI's zero/default behavior for invalid prefetch ranges. + #[test] + fn sanitizes_prefetch_ranges() { + assert_eq!(sanitize_prefetch(-1), 0); + assert_eq!(sanitize_prefetch(42), 42); + assert_eq!(sanitize_prefetch(i64::from(u32::MAX / 1024) + 1), DEFAULT_PREFETCH); + } + + /// Exercises the native Oracle client against the configured live database. + #[test] + #[ignore] + fn live_oci_round_trip() { + let dsn = std::env::var("ELEPHC_OCI_DSN") + .expect("ELEPHC_OCI_DSN is required for the ignored PDO_OCI live test"); + let mut connection = OciConn::open(&dsn).expect("open Oracle test database"); + let _ = connection.exec( + "BEGIN EXECUTE IMMEDIATE 'DROP TABLE ELEPHC_PDO_OCI_BRIDGE'; EXCEPTION WHEN OTHERS THEN NULL; END;", + ); + assert_eq!( + connection.exec( + "CREATE TABLE ELEPHC_PDO_OCI_BRIDGE (ID NUMBER NOT NULL, NAME VARCHAR2(80), DATA BLOB)" + ), + 0 + ); + + let mut insert = OciStmt::new( + &mut connection, + 1, + "INSERT INTO ELEPHC_PDO_OCI_BRIDGE (ID, NAME, DATA) VALUES (:id, :name, :data)", + ) + .unwrap(); + assert!(insert.bind_int(1, 7)); + assert!(insert.bind_text(2, "Éléphant".as_bytes().to_vec())); + assert!(insert.bind_blob(3, b"A\0B".to_vec())); + insert.execute(&mut connection).unwrap(); + assert_eq!(connection.changes, 1); + + let mut input_output = OciStmt::new(&mut connection, 1, "BEGIN :p := :p + 100; END;") + .unwrap(); + assert!(input_output.bind_int(1, -1)); + assert!(input_output.bind_output(1, 1, 10)); + input_output.execute(&mut connection).unwrap(); + assert_eq!(input_output.output_value(1).unwrap().data.as_deref(), Some(b"99".as_slice())); + + let mut lob_output = OciStmt::new( + &mut connection, + 1, + "BEGIN SELECT DATA INTO :data FROM ELEPHC_PDO_OCI_BRIDGE WHERE ID = 7; END;", + ) + .unwrap(); + assert!(lob_output.bind_null(1)); + assert!(lob_output.bind_output(1, 3, 0)); + lob_output.execute(&mut connection).unwrap(); + let output = lob_output.output_value(1).unwrap(); + assert!(output.lob); + assert_eq!(output.data.as_deref(), Some(b"A\0B".as_slice())); + + let mut select = OciStmt::new( + &mut connection, + 1, + "SELECT ID, NAME, DATA FROM ELEPHC_PDO_OCI_BRIDGE ORDER BY ID", + ) + .unwrap(); + select.execute(&mut connection).unwrap(); + assert_eq!(select.step(), 1); + assert_eq!(select.column_data(0), b"7"); + assert_eq!(select.column_data(1), "Éléphant".as_bytes()); + assert_eq!(select.column_data(2), b"A\0B"); + assert_eq!(select.column_native_type(0), "NUMBER"); + assert_eq!(select.column_pdo_type(2), 3); + + assert!(connection.begin()); + assert_eq!( + connection.exec( + "INSERT INTO ELEPHC_PDO_OCI_BRIDGE (ID, NAME, DATA) VALUES (8, 'rollback', empty_blob())" + ), + 1 + ); + assert!(connection.rollback()); + let mut count = OciStmt::new( + &mut connection, + 1, + "SELECT COUNT(*) FROM ELEPHC_PDO_OCI_BRIDGE", + ) + .unwrap(); + count.execute(&mut connection).unwrap(); + assert_eq!(count.step(), 1); + assert_eq!(count.column_data(0), b"1"); + assert_eq!(connection.exec("DROP TABLE ELEPHC_PDO_OCI_BRIDGE"), 0); + } +} diff --git a/crates/elephc-pdo/src/odbc.rs b/crates/elephc-pdo/src/odbc.rs new file mode 100644 index 0000000000..b8a3815b43 --- /dev/null +++ b/crates/elephc-pdo/src/odbc.rs @@ -0,0 +1,3985 @@ +//! Purpose: +//! System CLI backend matching PDO_ODBC, PDO_INFORMIX, PDO_IBM, and PDO_SQLSRV. +//! +//! Called from: +//! - The PDO bridge root with the optional `odbc`, `informix`, `ibm`, or `sqlsrv` feature. +//! +//! Key details: +//! - Uses the ODBC 3 CLI ABI through `odbc-sys`, as the official drivers delegate to a driver manager. +//! - Materializes scalar result rows as text/null and preserves driver-specific LOB/type metadata. +//! - Keeps statement handles alive across `SQLMoreResults`, cursor-name, and scroll operations. + +use std::collections::HashMap; +use std::ffi::c_void; +use std::ptr; +use std::sync::{Mutex, OnceLock}; + +use odbc_sys::{ + AttrOdbcVersion, CDataType, CompletionType, ConnectionAttribute, Desc, DriverConnectOption, + EnvironmentAttribute, FetchOrientation, FreeStmtOption, HDbc, HEnv, HStmt, Handle, HandleType, + InfoType, NULL_DATA, Nullability, ParamType, SqlDataType, SqlReturn, SQLAllocHandle, + SQLBindParameter, SQLCloseCursor, SQLColAttribute, SQLConnect, SQLDescribeCol, SQLDescribeParam, SQLDisconnect, SQLDriverConnect, + SQLEndTran, SQLExecDirect, SQLExecute, SQLFetch, SQLFreeHandle, SQLFreeStmt, + SQLGetData, SQLGetDiagRec, SQLGetInfo, SQLMoreResults, SQLNumParams, SQLNumResultCols, + SQLPrepare, SQLPrepareW, SQLRowCount, SQLSetConnectAttr, SQLSetEnvAttr, SQLSetStmtAttr, + SQLDrivers, SQLDriverConnectW, SQLExecDirectW, StatementAttribute, +}; +#[cfg(feature = "sqlsrv")] +use odbc_sys::{HDesc, SQLColAttributeW, SQLDescribeColW, SQLGetStmtAttr}; + +const SQL_AUTOCOMMIT_OFF: isize = 0; +const SQL_AUTOCOMMIT_ON: isize = 1; +const SQL_CUR_USE_IF_NEEDED: i64 = 0; +const SQL_CUR_USE_ODBC: i64 = 1; +const SQL_CUR_USE_DRIVER: i64 = 2; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ATTR_ENCODING: i64 = 1000; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ATTR_QUERY_TIMEOUT: i64 = 1001; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ATTR_DIRECT_QUERY: i64 = 1002; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ATTR_CURSOR_SCROLL_TYPE: i64 = 1003; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE: i64 = 1004; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ATTR_FETCHES_NUMERIC_TYPE: i64 = 1005; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ATTR_FETCHES_DATETIME_TYPE: i64 = 1006; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ATTR_FORMAT_DECIMALS: i64 = 1007; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ATTR_DECIMAL_PLACES: i64 = 1008; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ATTR_DATA_CLASSIFICATION: i64 = 1009; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ENCODING_DEFAULT: i64 = 1; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ENCODING_BINARY: i64 = 2; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ENCODING_SYSTEM: i64 = 3; +#[cfg(feature = "sqlsrv")] +const SQLSRV_ENCODING_UTF8: i64 = 65001; +#[cfg(feature = "sqlsrv")] +const SQL_COPT_SS_ACCESS_TOKEN: i32 = 1256; +#[cfg(feature = "sqlsrv")] +const SQL_COPT_SS_DATACLASSIFICATION_VERSION: i32 = 1400; +#[cfg(feature = "sqlsrv")] +const SQL_CA_SS_DATA_CLASSIFICATION: i16 = 1237; +#[cfg(feature = "sqlsrv")] +const SQL_CA_SS_DATA_CLASSIFICATION_VERSION: i16 = 1238; +#[cfg(feature = "informix")] +const SQL_INFX_ATTR_ODBC_TYPES_ONLY: i32 = 2257; +#[cfg(feature = "informix")] +const SQL_INFX_ATTR_LO_AUTOMATIC: i32 = 2262; +#[cfg(feature = "ibm")] +const PDO_IBM_ATTR_INFO_USERID: i32 = 1281; +#[cfg(feature = "ibm")] +const PDO_IBM_ATTR_INFO_ACCTSTR: i32 = 1282; +#[cfg(feature = "ibm")] +const PDO_IBM_ATTR_INFO_APPLNAME: i32 = 1283; +#[cfg(feature = "ibm")] +const PDO_IBM_ATTR_INFO_WRKSTNNAME: i32 = 1284; +#[cfg(feature = "ibm")] +const PDO_IBM_ATTR_USE_TRUSTED_CONTEXT: i32 = 2561; +#[cfg(feature = "ibm")] +const PDO_IBM_ATTR_TRUSTED_CONTEXT_USERID: i32 = 2562; +#[cfg(feature = "ibm")] +const PDO_IBM_ATTR_TRUSTED_CONTEXT_PASSWORD: i32 = 2563; +#[cfg(feature = "ibm")] +const SQL_IBM_ATTR_GET_GENERATED_VALUE: i32 = 2583; + +unsafe extern "system" { + /// Applies a driver-specific numeric connection attribute not modeled by `odbc-sys`. + #[cfg(any(feature = "informix", feature = "ibm", feature = "sqlsrv"))] + #[link_name = "SQLSetConnectAttr"] + fn SQLSetConnectAttrRaw( + connection_handle: HDbc, + attribute: i32, + value: *mut c_void, + string_length: i32, + ) -> SqlReturn; + /// Reads a driver-specific connection attribute not modeled by `odbc-sys`. + #[cfg(feature = "ibm")] + #[link_name = "SQLGetConnectAttr"] + fn SQLGetConnectAttrRaw( + connection_handle: HDbc, + attribute: i32, + value: *mut c_void, + buffer_length: i32, + string_length: *mut i32, + ) -> SqlReturn; + /// Reads IBM's generated-value statement attribute not modeled by `odbc-sys`. + #[cfg(feature = "ibm")] + #[link_name = "SQLGetStmtAttr"] + fn SQLGetStmtAttrRaw( + statement_handle: HStmt, + attribute: i32, + value: *mut c_void, + buffer_length: i32, + string_length: *mut i32, + ) -> SqlReturn; + /// Reads a Microsoft implementation-row-descriptor field outside `odbc-sys`'s enum. + #[cfg(feature = "sqlsrv")] + #[link_name = "SQLGetDescFieldW"] + fn SQLGetDescFieldWRaw( + descriptor_handle: HDesc, + record_number: i16, + field_identifier: i16, + value: *mut c_void, + buffer_length: i32, + string_length: *mut i32, + ) -> SqlReturn; + /// Assigns an ANSI cursor name to a prepared ODBC statement. + fn SQLSetCursorName( + statement_handle: HStmt, + cursor_name: *const u8, + name_length: i16, + ) -> SqlReturn; + /// Reads the ANSI cursor name assigned to a prepared ODBC statement. + fn SQLGetCursorName( + statement_handle: HStmt, + cursor_name: *mut u8, + buffer_length: i16, + name_length: *mut i16, + ) -> SqlReturn; +} + +/// Selects the PDO extension semantics layered over the shared CLI ABI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CliFlavor { + #[cfg(feature = "odbc")] + Odbc, + #[cfg(feature = "informix")] + Informix, + #[cfg(feature = "ibm")] + Ibm, + #[cfg(feature = "sqlsrv")] + Sqlsrv, +} + +/// Maps PDO_IBM's public sequential constants to IBM CLI's native attribute IDs. +#[cfg(feature = "ibm")] +fn ibm_native_connection_attribute(attribute: i32) -> Option { + match attribute { + PDO_IBM_ATTR_INFO_USERID => Some(1281), + PDO_IBM_ATTR_INFO_ACCTSTR => Some(1284), + PDO_IBM_ATTR_INFO_APPLNAME => Some(1283), + PDO_IBM_ATTR_INFO_WRKSTNNAME => Some(1282), + PDO_IBM_ATTR_USE_TRUSTED_CONTEXT => Some(2561), + PDO_IBM_ATTR_TRUSTED_CONTEXT_USERID => Some(2562), + PDO_IBM_ATTR_TRUSTED_CONTEXT_PASSWORD => Some(2563), + _ => None, + } +} + +impl CliFlavor { + /// Returns the exact PDO DSN prefix owned by this extension. + fn dsn_prefix(self) -> &'static str { + match self { + #[cfg(feature = "odbc")] + Self::Odbc => "odbc:", + #[cfg(feature = "informix")] + Self::Informix => "informix:", + #[cfg(feature = "ibm")] + Self::Ibm => "ibm:", + #[cfg(feature = "sqlsrv")] + Self::Sqlsrv => "sqlsrv:", + } + } + + /// Reports whether this flavor implements Microsoft's PDO_SQLSRV extension. + fn is_sqlsrv(self) -> bool { + #[cfg(feature = "sqlsrv")] + if self == Self::Sqlsrv { + return true; + } + false + } +} + +/// PDO-visible ODBC diagnostic record. +#[derive(Clone)] +struct ErrorState { + sqlstate: String, + native_code: i64, + message: String, +} + +impl Default for ErrorState { + /// Creates the successful/no-error PDO state. + fn default() -> Self { + Self { + sqlstate: "00000".to_string(), + native_code: 0, + message: String::new(), + } + } +} + +/// Holds the diagnostic produced before a CLI connection handle enters the bridge table. +fn open_error_cell() -> &'static Mutex { + static ERROR: OnceLock> = OnceLock::new(); + ERROR.get_or_init(|| Mutex::new(ErrorState::default())) +} + +/// Records one constructor failure for PDO's connection-level `errorInfo` fields. +fn remember_open_error(error: &ErrorState) { + *open_error_cell() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = error.clone(); +} + +/// Returns the SQLSTATE and native code captured by the latest failed CLI open. +pub(crate) fn open_diagnostic() -> (String, i64) { + let error = open_error_cell() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + (error.sqlstate.clone(), error.native_code) +} + +/// Reports whether an ODBC return code completed successfully. +fn succeeded(result: SqlReturn) -> bool { + matches!(result, SqlReturn::SUCCESS | SqlReturn::SUCCESS_WITH_INFO) +} + +/// Reads the first ODBC diagnostic record from a native handle. +fn diagnostic(handle_type: HandleType, handle: Handle, context: &str) -> ErrorState { + let mut state = [0u8; 6]; + let mut native = 0i32; + let mut message = [0u8; 1024]; + let mut length = 0i16; + let result = unsafe { + SQLGetDiagRec( + handle_type, + handle, + 1, + state.as_mut_ptr(), + &mut native, + message.as_mut_ptr(), + message.len() as i16, + &mut length, + ) + }; + if !succeeded(result) { + return ErrorState { + sqlstate: "HY000".to_string(), + native_code: 0, + message: context.to_string(), + }; + } + let state_len = state.iter().position(|byte| *byte == 0).unwrap_or(5); + let message_len = usize::try_from(length).unwrap_or(0).min(message.len()); + ErrorState { + sqlstate: String::from_utf8_lossy(&state[..state_len]).into_owned(), + native_code: i64::from(native), + message: format!("{context}: {}", String::from_utf8_lossy(&message[..message_len])), + } +} + +/// Percent-decodes constructor credentials folded into the internal bridge DSN. +fn decode_credential(value: &str) -> String { + value + .replace("%3B", ";") + .replace("%3b", ";") + .replace("%25", "%") +} + +/// Quotes a constructor credential for an ODBC connection string. +fn quote_connection_value(value: &str) -> String { + if value.starts_with('{') && value.ends_with('}') { + value.to_string() + } else if value.contains(';') || value.contains('}') { + format!("{{{}}}", value.replace('}', "}}")) + } else { + value.to_string() + } +} + +/// Computes the stable token fingerprint PDO_SQLSRV adds to the pooling key. +#[cfg(feature = "sqlsrv")] +fn sqlsrv_token_fingerprint(token: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in token { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Builds Microsoft's aligned `ACCESSTOKEN` header plus zero-padded token bytes. +#[cfg(feature = "sqlsrv")] +fn sqlsrv_access_token_buffer(token: &[u8]) -> Vec { + let payload_len = token.len().saturating_mul(2); + let byte_len = 4usize.saturating_add(payload_len); + let mut buffer = vec![0u32; byte_len.saturating_add(3) / 4]; + buffer[0] = u32::try_from(payload_len).unwrap_or(u32::MAX); + let bytes = unsafe { + std::slice::from_raw_parts_mut(buffer.as_mut_ptr().cast::(), buffer.len() * 4) + }; + for (index, byte) in token.iter().enumerate() { + bytes[4 + index * 2] = *byte; + } + buffer +} + +/// Reads unixODBC/iODBC's process-level `[ODBC] Pooling` switch. +#[cfg(feature = "sqlsrv")] +fn sqlsrv_driver_manager_pooling_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + let file_name = std::env::var_os("ODBCINSTINI") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from("odbcinst.ini")); + let mut candidates = Vec::new(); + if file_name.is_absolute() { + candidates.push(file_name.clone()); + } + if let Some(directory) = std::env::var_os("ODBCSYSINI") { + candidates.push(std::path::PathBuf::from(directory).join(&file_name)); + } + candidates.extend([ + std::path::PathBuf::from("/etc").join(&file_name), + std::path::PathBuf::from("/usr/local/etc").join(&file_name), + std::path::PathBuf::from("/opt/homebrew/etc").join(&file_name), + ]); + for candidate in candidates { + let Ok(contents) = std::fs::read_to_string(candidate) else { + continue; + }; + if let Some(enabled) = sqlsrv_pooling_from_ini(&contents) { + return enabled; + } + } + false + }) +} + +/// Extracts `[ODBC] Pooling` from one driver-manager INI document. +#[cfg(feature = "sqlsrv")] +fn sqlsrv_pooling_from_ini(contents: &str) -> Option { + let mut in_odbc = false; + for line in contents.lines() { + let line = line.trim(); + if line.starts_with('[') && line.ends_with(']') { + in_odbc = line[1..line.len() - 1].eq_ignore_ascii_case("odbc"); + continue; + } + if !in_odbc || line.starts_with(';') || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if key.trim().eq_ignore_ascii_case("pooling") { + return Some(matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "yes" | "on" | "true" + )); + } + } + None +} + +/// Returns the process-lifetime pooled SQLSRV ODBC environment. +#[cfg(feature = "sqlsrv")] +fn sqlsrv_pooled_environment() -> Result { + static ENVIRONMENT: OnceLock> = OnceLock::new(); + match ENVIRONMENT.get_or_init(|| { + let mut env = Handle::null(); + if !succeeded(unsafe { SQLAllocHandle(HandleType::Env, Handle::null(), &mut env) }) { + return Err("SQLAllocHandle: pooled ENV failed".to_string()); + } + let version = unsafe { + SQLSetEnvAttr( + env.as_henv(), + EnvironmentAttribute::OdbcVersion, + AttrOdbcVersion::Odbc3.into(), + 0, + ) + }; + let pooling = unsafe { + SQLSetEnvAttr( + env.as_henv(), + EnvironmentAttribute::ConnectionPooling, + 2isize as *mut c_void, + odbc_sys::IS_UINTEGER, + ) + }; + if !succeeded(version) || !succeeded(pooling) { + unsafe { let _ = SQLFreeHandle(HandleType::Env, env); }; + return Err("SQLSetEnvAttr: pooled ODBC3 environment failed".to_string()); + } + Ok(env.0 as usize) + }) { + Ok(pointer) => Ok(Handle(*pointer as *mut c_void)), + Err(message) => Err(message.clone()), + } +} + +/// Frees a connection-private ODBC environment while retaining shared pooled ones. +fn free_environment_if_owned(env: Handle, owned: bool) { + if owned { + unsafe { let _ = SQLFreeHandle(HandleType::Env, env); }; + } +} + +/// Allocates one connection-private ODBC 3 environment. +fn private_odbc_environment() -> Result { + let mut env = Handle::null(); + if !succeeded(unsafe { SQLAllocHandle(HandleType::Env, Handle::null(), &mut env) }) { + return Err("SQLAllocHandle: ENV failed".to_string()); + } + let version = unsafe { + SQLSetEnvAttr( + env.as_henv(), + EnvironmentAttribute::OdbcVersion, + AttrOdbcVersion::Odbc3.into(), + 0, + ) + }; + if !succeeded(version) { + free_environment_if_owned(env, true); + return Err("SQLSetEnvAttr: ODBC3 failed".to_string()); + } + Ok(env) +} + +/// Selects SQLSRV's externally configured pooled environment or a private one. +#[cfg(feature = "sqlsrv")] +fn connection_environment(flavor: CliFlavor) -> Result<(Handle, bool), String> { + if flavor.is_sqlsrv() && sqlsrv_driver_manager_pooling_enabled() { + return sqlsrv_pooled_environment().map(|env| (env, false)); + } + private_odbc_environment().map(|env| (env, true)) +} + +/// Selects a private environment when PDO_SQLSRV is not in this bridge build. +#[cfg(not(feature = "sqlsrv"))] +fn connection_environment(_flavor: CliFlavor) -> Result<(Handle, bool), String> { + private_odbc_environment().map(|env| (env, true)) +} + +/// Parsed ODBC DSN and bridge-only constructor options. +struct OpenOptions { + source: String, + username: String, + password: String, + #[cfg(feature = "sqlsrv")] + username_supplied: bool, + #[cfg(feature = "sqlsrv")] + password_supplied: bool, + cursor_library: i64, + assume_utf8: bool, + auto_commit: bool, + #[cfg(feature = "sqlsrv")] + sqlsrv_access_token: Option>, + #[cfg(feature = "ibm")] + ibm_attributes: Vec<(i32, String)>, +} + +/// Enumerates installed ODBC drivers and selects Microsoft's newest SQL Server driver. +fn sql_server_driver(env: HEnv) -> Option { + let mut direction = FetchOrientation::First; + let mut candidates = Vec::new(); + loop { + let mut description = [0u8; 256]; + let mut description_len = 0i16; + let mut attributes = [0u8; 1024]; + let mut attributes_len = 0i16; + let result = unsafe { + SQLDrivers( + env, + direction, + description.as_mut_ptr(), + description.len() as i16, + &mut description_len, + attributes.as_mut_ptr(), + attributes.len() as i16, + &mut attributes_len, + ) + }; + if result == SqlReturn::NO_DATA { + break; + } + if !succeeded(result) { + return None; + } + let length = usize::try_from(description_len).unwrap_or(0).min(description.len()); + let name = String::from_utf8_lossy(&description[..length]).into_owned(); + if name.to_ascii_lowercase().contains("sql server") { + candidates.push(name); + } + direction = FetchOrientation::Next; + } + candidates.into_iter().max_by_key(|name| { + let lower = name.to_ascii_lowercase(); + if lower.contains("odbc driver 18") { + 18 + } else if lower.contains("odbc driver 17") { + 17 + } else { + 0 + } + }) +} + +/// Splits an ODBC connection string without treating semicolons inside braced values as separators. +fn split_connection_fields(body: &str) -> Vec<&str> { + let bytes = body.as_bytes(); + let mut fields = Vec::new(); + let mut start = 0usize; + let mut braced = false; + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'{' if !braced => braced = true, + b'}' if braced => { + if bytes.get(index + 1) == Some(&b'}') { + index += 1; + } else { + braced = false; + } + } + b';' if !braced => { + fields.push(&body[start..index]); + start = index + 1; + } + _ => {} + } + index += 1; + } + fields.push(&body[start..]); + fields +} + +/// Separates PDO_ODBC's DSN from bridge-only constructor fields. +fn parse_open_options(dsn: &str, flavor: CliFlavor) -> Result { + let body = dsn + .strip_prefix(flavor.dsn_prefix()) + .ok_or_else(|| "could not find driver".to_string())?; + let mut source_parts = Vec::new(); + let mut username = String::new(); + let mut password = String::new(); + #[cfg(feature = "sqlsrv")] + let mut username_supplied = false; + #[cfg(feature = "sqlsrv")] + let mut password_supplied = false; + let mut cursor_library = SQL_CUR_USE_IF_NEEDED; + let mut assume_utf8 = false; + let mut auto_commit = true; + #[cfg(feature = "ibm")] + let mut ibm_attributes = Vec::new(); + #[cfg(feature = "sqlsrv")] + let mut sqlsrv_access_token = None; + for part in split_connection_fields(body) { + let lower = part.to_ascii_lowercase(); + if let Some(value) = lower.strip_prefix("user=") { + let offset = part.len() - value.len(); + username = decode_credential(&part[offset..]); + #[cfg(feature = "sqlsrv")] + { + username_supplied = true; + } + } else if let Some(value) = lower.strip_prefix("password=") { + let offset = part.len() - value.len(); + password = decode_credential(&part[offset..]); + #[cfg(feature = "sqlsrv")] + { + password_supplied = true; + } + } else if flavor.is_sqlsrv() && lower.starts_with("accesstoken=") { + #[cfg(feature = "sqlsrv")] + { + let value = &part["accesstoken=".len()..]; + let value = value + .strip_prefix('{') + .and_then(|value| value.strip_suffix('}')) + .unwrap_or(value) + .replace("}}", "}"); + if value.is_empty() { + return Err("Access token must not be empty".to_string()); + } + sqlsrv_access_token = Some(value.into_bytes()); + } + } else if let Some(value) = lower.strip_prefix("elephc_odbc_cursor_library=") { + cursor_library = value.parse().unwrap_or(SQL_CUR_USE_IF_NEEDED); + } else if let Some(value) = lower.strip_prefix("elephc_odbc_assume_utf8=") { + assume_utf8 = value != "0"; + } else if let Some(value) = lower.strip_prefix("elephc_odbc_autocommit=") { + auto_commit = value != "0"; + } else if let Some(value) = lower.strip_prefix("elephc_ibm_attr_") { + #[cfg(feature = "ibm")] + if let Some((attribute, _)) = value.split_once('=') { + let key_length = "elephc_ibm_attr_".len() + attribute.len() + 1; + if let Ok(attribute) = attribute.parse() { + ibm_attributes.push((attribute, decode_credential(&part[key_length..]))); + } + } + #[cfg(not(feature = "ibm"))] + let _ = value; + } else if lower.starts_with("connect_timeout=") { + // PDO_ODBC does not implement PDO::ATTR_TIMEOUT; the common prelude + // folds it for network drivers, so discard it before DriverConnect. + } else if flavor.is_sqlsrv() && lower.starts_with("connectionpooling=") { + // On Unix-like targets current PDO_SQLSRV ignores this DSN option and + // lets the ODBC manager's ODBCINST.INI Pooling setting select pooling. + } else if !part.is_empty() { + source_parts.push(part); + } + } + if !matches!(cursor_library, SQL_CUR_USE_IF_NEEDED | SQL_CUR_USE_ODBC | SQL_CUR_USE_DRIVER) { + return Err("Pdo\\Odbc::ATTR_USE_CURSOR_LIBRARY must be a valid SQL_USE_* value".to_string()); + } + Ok(OpenOptions { + source: source_parts.join(";"), + username, + password, + #[cfg(feature = "sqlsrv")] + username_supplied, + #[cfg(feature = "sqlsrv")] + password_supplied, + cursor_library, + assume_utf8, + auto_commit, + #[cfg(feature = "sqlsrv")] + sqlsrv_access_token, + #[cfg(feature = "ibm")] + ibm_attributes, + }) +} + +/// Live ODBC environment/connection pair and PDO state. +pub struct OdbcConn { + env: HEnv, + owns_env: bool, + dbc: HDbc, + error: ErrorState, + pub changes: i64, + pub in_transaction: bool, + auto_commit: bool, + assume_utf8: bool, + flavor: CliFlavor, + last_insert_id: i64, + #[cfg(feature = "sqlsrv")] + sqlsrv_encoding: i64, + #[cfg(feature = "sqlsrv")] + sqlsrv_query_timeout: i64, + #[cfg(feature = "sqlsrv")] + sqlsrv_direct_query: bool, + #[cfg(feature = "sqlsrv")] + sqlsrv_client_buffer_kb: i64, + #[cfg(feature = "sqlsrv")] + sqlsrv_fetch_numeric: bool, + #[cfg(feature = "sqlsrv")] + sqlsrv_fetch_datetime: bool, + #[cfg(feature = "sqlsrv")] + sqlsrv_format_decimals: bool, + #[cfg(feature = "sqlsrv")] + sqlsrv_decimal_places: i64, + #[cfg(feature = "sqlsrv")] + sqlsrv_default_str_param: i64, + #[cfg(feature = "sqlsrv")] + sqlsrv_emulate_prepares: bool, + #[cfg(feature = "sqlsrv")] + _sqlsrv_access_token: Option>, +} + +// The bridge serializes access through its global connection-table mutex. +unsafe impl Send for OdbcConn {} + +impl Drop for OdbcConn { + /// Disconnects and frees the ODBC handles in dependency order. + fn drop(&mut self) { + unsafe { + if !self.dbc.0.is_null() { + if self.in_transaction || !self.auto_commit { + let _ = SQLEndTran(HandleType::Dbc, self.dbc.as_handle(), CompletionType::Rollback); + } + let _ = SQLDisconnect(self.dbc); + let _ = SQLFreeHandle(HandleType::Dbc, self.dbc.as_handle()); + } + if self.owns_env && !self.env.0.is_null() { + let _ = SQLFreeHandle(HandleType::Env, self.env.as_handle()); + } + } + } +} + +impl OdbcConn { + /// Opens a PDO_ODBC named data source or direct connection string. + #[cfg(feature = "odbc")] + pub fn open_odbc(dsn: &str) -> Result { + Self::open(dsn, CliFlavor::Odbc) + } + + /// Opens a PDO_INFORMIX named data source or direct CLI connection string. + #[cfg(feature = "informix")] + pub fn open_informix(dsn: &str) -> Result { + Self::open(dsn, CliFlavor::Informix) + } + + /// Opens a PDO_IBM named data source or direct IBM CLI connection string. + #[cfg(feature = "ibm")] + pub fn open_ibm(dsn: &str) -> Result { + Self::open(dsn, CliFlavor::Ibm) + } + + /// Opens a PDO_SQLSRV DSN through Microsoft ODBC Driver 18 or 17. + #[cfg(feature = "sqlsrv")] + pub fn open_sqlsrv(dsn: &str) -> Result { + Self::open(dsn, CliFlavor::Sqlsrv) + } + + /// Opens either CLI flavor while retaining its distinct PDO identity. + fn open(dsn: &str, flavor: CliFlavor) -> Result { + remember_open_error(&ErrorState { + sqlstate: "HY000".to_string(), + native_code: 0, + message: "CLI connection initialization failed".to_string(), + }); + let mut options = parse_open_options(dsn, flavor)?; + #[cfg(feature = "sqlsrv")] + let sqlsrv_token = if flavor.is_sqlsrv() { + options.sqlsrv_access_token.take() + } else { + None + }; + #[cfg(feature = "sqlsrv")] + if let Some(token) = sqlsrv_token.as_deref() { + let conflicting_source_option = split_connection_fields(&options.source) + .iter() + .filter_map(|field| field.split_once('=').map(|(key, _)| key.trim())) + .any(|key| { + key.eq_ignore_ascii_case("uid") + || key.eq_ignore_ascii_case("pwd") + || key.eq_ignore_ascii_case("authentication") + }); + if options.username_supplied + || options.password_supplied + || conflicting_source_option + { + return Err( + "AccessToken cannot be combined with username, password, or Authentication" + .to_string(), + ); + } + let fingerprint = sqlsrv_token_fingerprint(token); + if !options.source.is_empty() && !options.source.ends_with(';') { + options.source.push(';'); + } + options + .source + .push_str(&format!("APP={{MSPHPSQL AT-{fingerprint:016x}}}")); + } + #[cfg(feature = "sqlsrv")] + let mut sqlsrv_access_token = sqlsrv_token + .as_deref() + .map(sqlsrv_access_token_buffer); + let (env, owns_env) = connection_environment(flavor)?; + let mut dbc = Handle::null(); + if flavor.is_sqlsrv() + && !split_connection_fields(&options.source).iter().any(|field| { + field + .split_once('=') + .is_some_and(|(key, _)| key.trim().eq_ignore_ascii_case("driver")) + }) + { + let Some(driver) = sql_server_driver(env.as_henv()) else { + free_environment_if_owned(env, owns_env); + return Err("Microsoft ODBC Driver 18 or 17 for SQL Server is not installed".to_string()); + }; + options.source = format!("Driver={{{driver}}};{}", options.source); + } + let allocated_dbc = unsafe { SQLAllocHandle(HandleType::Dbc, env, &mut dbc) }; + if !succeeded(allocated_dbc) { + free_environment_if_owned(env, owns_env); + return Err("SQLAllocHandle: DBC failed".to_string()); + } + let dbc_handle = dbc.as_hdbc(); + #[cfg(feature = "sqlsrv")] + if let Some(token) = sqlsrv_access_token.as_mut() { + let result = unsafe { + SQLSetConnectAttrRaw( + dbc_handle, + SQL_COPT_SS_ACCESS_TOKEN, + token.as_mut_ptr().cast(), + odbc_sys::IS_POINTER, + ) + }; + if !succeeded(result) { + let error = diagnostic( + HandleType::Dbc, + dbc, + "SQLSetConnectAttr SQL_COPT_SS_ACCESS_TOKEN", + ); + remember_open_error(&error); + unsafe { + let _ = SQLFreeHandle(HandleType::Dbc, dbc); + } + free_environment_if_owned(env, owns_env); + return Err(error.message); + } + } + #[cfg(feature = "sqlsrv")] + if flavor.is_sqlsrv() { + let _ = unsafe { + SQLSetConnectAttrRaw( + dbc_handle, + SQL_COPT_SS_DATACLASSIFICATION_VERSION, + 2isize as *mut c_void, + odbc_sys::IS_POINTER, + ) + }; + } + let set_autocommit = unsafe { + SQLSetConnectAttr( + dbc_handle, + ConnectionAttribute::AUTOCOMMIT, + (if options.auto_commit { SQL_AUTOCOMMIT_ON } else { SQL_AUTOCOMMIT_OFF }) as *mut c_void, + odbc_sys::IS_INTEGER, + ) + }; + if !succeeded(set_autocommit) { + let error = diagnostic(HandleType::Dbc, dbc, "SQLSetConnectAttr AUTOCOMMIT"); + remember_open_error(&error); + unsafe { + let _ = SQLFreeHandle(HandleType::Dbc, dbc); + } + free_environment_if_owned(env, owns_env); + return Err(error.message); + } + #[cfg(feature = "ibm")] + if flavor == CliFlavor::Ibm { + for (attribute, value) in &options.ibm_attributes { + let Some(native_attribute) = ibm_native_connection_attribute(*attribute) else { + continue; + }; + let result = if *attribute == PDO_IBM_ATTR_USE_TRUSTED_CONTEXT { + let enabled = (value != "0") as isize; + unsafe { + SQLSetConnectAttrRaw( + dbc_handle, + native_attribute, + enabled as *mut c_void, + odbc_sys::IS_INTEGER, + ) + } + } else { + unsafe { + SQLSetConnectAttrRaw( + dbc_handle, + native_attribute, + value.as_ptr().cast_mut().cast(), + value.len() as i32, + ) + } + }; + if !succeeded(result) { + let error = diagnostic(HandleType::Dbc, dbc, "SQLSetConnectAttr IBM"); + remember_open_error(&error); + unsafe { + let _ = SQLFreeHandle(HandleType::Dbc, dbc); + } + free_environment_if_owned(env, owns_env); + return Err(error.message); + } + } + } + let cursor_result = unsafe { + SQLSetConnectAttr( + dbc_handle, + ConnectionAttribute::ODBC_CURSORS, + options.cursor_library as isize as *mut c_void, + odbc_sys::IS_INTEGER, + ) + }; + if !succeeded(cursor_result) && options.cursor_library != SQL_CUR_USE_IF_NEEDED { + let error = diagnostic(HandleType::Dbc, dbc, "SQLSetConnectAttr SQL_ODBC_CURSORS"); + remember_open_error(&error); + unsafe { + let _ = SQLFreeHandle(HandleType::Dbc, dbc); + } + free_environment_if_owned(env, owns_env); + return Err(error.message); + } + + let direct = options.source.contains('='); + let connect_result = if direct { + let mut source = options.source.trim_end_matches(';').to_string(); + let lower = source.to_ascii_lowercase(); + if !options.username.is_empty() && !lower.contains("uid=") { + source.push_str(";UID="); + source.push_str("e_connection_value(&options.username)); + } + if !options.password.is_empty() && !lower.contains("pwd=") { + source.push_str(";PWD="); + source.push_str("e_connection_value(&options.password)); + } + if flavor.is_sqlsrv() { + let source = source.encode_utf16().collect::>(); + let mut completed = [0u16; 1024]; + let mut completed_len = 0i16; + unsafe { + SQLDriverConnectW( + dbc_handle, + ptr::null_mut(), + source.as_ptr(), + source.len() as i16, + completed.as_mut_ptr(), + completed.len() as i16, + &mut completed_len, + DriverConnectOption::NoPrompt, + ) + } + } else { + let mut completed = [0u8; 1024]; + let mut completed_len = 0i16; + unsafe { + SQLDriverConnect( + dbc_handle, + ptr::null_mut(), + source.as_ptr(), + source.len() as i16, + completed.as_mut_ptr(), + completed.len() as i16, + &mut completed_len, + DriverConnectOption::NoPrompt, + ) + } + } + } else { + unsafe { + SQLConnect( + dbc_handle, + options.source.as_ptr(), + options.source.len() as i16, + options.username.as_ptr(), + options.username.len() as i16, + options.password.as_ptr(), + options.password.len() as i16, + ) + } + }; + if !succeeded(connect_result) { + let error = diagnostic( + HandleType::Dbc, + dbc, + if direct { "SQLDriverConnect" } else { "SQLConnect" }, + ); + remember_open_error(&error); + unsafe { + let _ = SQLFreeHandle(HandleType::Dbc, dbc); + } + free_environment_if_owned(env, owns_env); + return Err(error.message); + } + #[cfg(feature = "informix")] + if flavor == CliFlavor::Informix { + for (attribute, context) in [ + (SQL_INFX_ATTR_LO_AUTOMATIC, "SQL_INFX_ATTR_LO_AUTOMATIC"), + (SQL_INFX_ATTR_ODBC_TYPES_ONLY, "SQL_INFX_ATTR_ODBC_TYPES_ONLY"), + ] { + let result = unsafe { + SQLSetConnectAttrRaw( + dbc_handle, + attribute, + 1isize as *mut c_void, + odbc_sys::IS_INTEGER, + ) + }; + if !succeeded(result) { + let error = diagnostic(HandleType::Dbc, dbc, context); + remember_open_error(&error); + unsafe { + let _ = SQLDisconnect(dbc_handle); + let _ = SQLFreeHandle(HandleType::Dbc, dbc); + } + free_environment_if_owned(env, owns_env); + return Err(error.message); + } + } + } + Ok(Self { + env: env.as_henv(), + owns_env, + dbc: dbc_handle, + error: ErrorState::default(), + changes: 0, + in_transaction: false, + auto_commit: options.auto_commit, + assume_utf8: options.assume_utf8 || flavor.is_sqlsrv(), + flavor, + last_insert_id: 0, + #[cfg(feature = "sqlsrv")] + sqlsrv_encoding: SQLSRV_ENCODING_UTF8, + #[cfg(feature = "sqlsrv")] + sqlsrv_query_timeout: 0, + #[cfg(feature = "sqlsrv")] + sqlsrv_direct_query: false, + #[cfg(feature = "sqlsrv")] + sqlsrv_client_buffer_kb: 10_240, + #[cfg(feature = "sqlsrv")] + sqlsrv_fetch_numeric: false, + #[cfg(feature = "sqlsrv")] + sqlsrv_fetch_datetime: false, + #[cfg(feature = "sqlsrv")] + sqlsrv_format_decimals: false, + #[cfg(feature = "sqlsrv")] + sqlsrv_decimal_places: -1, + #[cfg(feature = "sqlsrv")] + sqlsrv_default_str_param: 0x2000_0000, + #[cfg(feature = "sqlsrv")] + sqlsrv_emulate_prepares: false, + #[cfg(feature = "sqlsrv")] + _sqlsrv_access_token: sqlsrv_access_token, + }) + } + + /// Returns the PDO registry identity selected when the CLI connection opened. + pub(crate) fn driver_kind(&self) -> crate::driver::DriverKind { + match self.flavor { + #[cfg(feature = "odbc")] + CliFlavor::Odbc => crate::driver::DriverKind::Odbc, + #[cfg(feature = "informix")] + CliFlavor::Informix => crate::driver::DriverKind::Informix, + #[cfg(feature = "ibm")] + CliFlavor::Ibm => crate::driver::DriverKind::Ibm, + #[cfg(feature = "sqlsrv")] + CliFlavor::Sqlsrv => crate::driver::DriverKind::Sqlsrv, + } + } + + /// Reports whether the driver manager considers the connection alive. + pub fn is_alive(&mut self) -> bool { + let mut dead = 0u32; + let result = unsafe { + odbc_sys::SQLGetConnectAttr( + self.dbc, + ConnectionAttribute::CONNECTION_DEAD, + (&mut dead as *mut u32).cast(), + 0, + ptr::null_mut(), + ) + }; + if succeeded(result) && dead != 0 { + return false; + } + let mut read_only = [0u8; 32]; + let mut length = 0i16; + let fallback = unsafe { + SQLGetInfo( + self.dbc, + InfoType::DataSourceReadOnly, + read_only.as_mut_ptr().cast(), + read_only.len() as i16, + &mut length, + ) + }; + succeeded(fallback) && length > 0 + } + + /// Executes one direct statement and returns its affected-row count. + pub fn exec(&mut self, sql: &str) -> i64 { + let mut statement = Handle::null(); + if !succeeded(unsafe { SQLAllocHandle(HandleType::Stmt, self.dbc.as_handle(), &mut statement) }) { + self.error = diagnostic(HandleType::Dbc, self.dbc.as_handle(), "SQLAllocHandle: STMT"); + return -1; + } + let statement_handle = statement.as_hstmt(); + let result = if self.is_sqlsrv() { + let sql = sql.encode_utf16().collect::>(); + unsafe { SQLExecDirectW(statement_handle, sql.as_ptr(), sql.len() as i32) } + } else { + unsafe { SQLExecDirect(statement_handle, sql.as_ptr(), sql.len() as i32) } + }; + if result == SqlReturn::NO_DATA { + self.changes = 0; + } else if !succeeded(result) { + self.error = diagnostic(HandleType::Stmt, statement, "SQLExecDirect"); + unsafe { let _ = SQLFreeHandle(HandleType::Stmt, statement); }; + return -1; + } else { + let mut count = -1; + if succeeded(unsafe { SQLRowCount(statement_handle, &mut count) }) { + self.changes = count.max(0) as i64; + } + } + self.error = ErrorState::default(); + if self.is_ibm() { + self.refresh_ibm_ids_last_insert_id(statement_handle); + } + unsafe { let _ = SQLFreeHandle(HandleType::Stmt, statement); }; + if self.is_informix() && sql.trim_start().to_ascii_lowercase().starts_with("insert") { + self.refresh_informix_last_insert_id(); + } + self.changes + } + + /// Reports whether this shared CLI handle belongs to PDO_INFORMIX. + fn is_informix(&self) -> bool { + #[cfg(feature = "informix")] + if self.flavor == CliFlavor::Informix { + return true; + } + false + } + + /// Reports whether this shared CLI handle belongs to PDO_IBM. + fn is_ibm(&self) -> bool { + #[cfg(feature = "ibm")] + if self.flavor == CliFlavor::Ibm { + return true; + } + false + } + + /// Reports whether this shared CLI handle belongs to PDO_ODBC itself. + fn is_odbc(&self) -> bool { + #[cfg(feature = "odbc")] + if self.flavor == CliFlavor::Odbc { + return true; + } + false + } + + /// Reports whether this shared CLI handle belongs to PDO_SQLSRV. + fn is_sqlsrv(&self) -> bool { + self.flavor.is_sqlsrv() + } + + /// Reads Informix's most recent SERIAL value without changing PDO error state. + fn refresh_informix_last_insert_id(&mut self) { + let mut raw = Handle::null(); + if !succeeded(unsafe { SQLAllocHandle(HandleType::Stmt, self.dbc.as_handle(), &mut raw) }) { + self.last_insert_id = 0; + return; + } + let statement = raw.as_hstmt(); + let sql = b"SELECT DBINFO('sqlca.sqlerrd1') FROM systables WHERE tabid = 1"; + let executed = unsafe { SQLExecDirect(statement, sql.as_ptr(), sql.len() as i32) }; + let fetched = succeeded(executed) && succeeded(unsafe { SQLFetch(statement) }); + let mut buffer = [0u8; 64]; + let mut indicator = 0isize; + let read = fetched + && succeeded(unsafe { + SQLGetData( + statement, + 1, + CDataType::Char, + buffer.as_mut_ptr().cast(), + buffer.len() as isize, + &mut indicator, + ) + }); + self.last_insert_id = if read && indicator != NULL_DATA { + let length = usize::try_from(indicator) + .unwrap_or(0) + .min(buffer.len().saturating_sub(1)); + String::from_utf8_lossy(&buffer[..length]) + .trim() + .parse() + .unwrap_or(0) + } else { + 0 + }; + unsafe { let _ = SQLFreeHandle(HandleType::Stmt, raw); }; + } + + /// Reads PDO_IBM's IDS generated-value statement attribute before handle release. + fn refresh_ibm_ids_last_insert_id(&mut self, statement: HStmt) { + #[cfg(feature = "ibm")] + { + if !self.is_ibm() || !self.server_info().starts_with("IDS") { + return; + } + let mut buffer = [0u8; 64]; + let result = unsafe { + SQLGetStmtAttrRaw( + statement, + SQL_IBM_ATTR_GET_GENERATED_VALUE, + buffer.as_mut_ptr().cast(), + buffer.len() as i32, + ptr::null_mut(), + ) + }; + if succeeded(result) { + let end = buffer.iter().position(|byte| *byte == 0).unwrap_or(buffer.len()); + let value = String::from_utf8_lossy(&buffer[..end]).trim().parse().unwrap_or(0); + if value != 0 { + self.last_insert_id = value; + } + } + } + #[cfg(not(feature = "ibm"))] + let _ = statement; + } + + /// Returns the driver-specific current identity or named SQL Server sequence value. + pub fn last_insert_id(&mut self, name: Option<&str>) -> String { + if self.is_informix() { + return self.last_insert_id.to_string(); + } + if self.is_ibm() { + let server = self.server_info(); + if server.starts_with("DB2") { + return self + .query_scalar_text("SELECT IDENTITY_VAL_LOCAL() FROM SYSIBM.SYSDUMMY1") + .unwrap_or_default(); + } + return self.last_insert_id.to_string(); + } + if self.is_sqlsrv() { + let sql = name.filter(|name| !name.is_empty()).map_or_else( + || "SELECT @@IDENTITY;".to_string(), + |name| { + let name = name.replace('\'', "''"); + format!("SELECT current_value FROM sys.sequences WHERE name=N'{name}'") + }, + ); + return self.query_scalar_text(&sql).unwrap_or_default(); + } + String::new() + } + + /// Executes one scalar CLI query for driver helper hooks such as last-insert-id. + fn query_scalar_text(&mut self, sql: &str) -> Option { + let mut raw = Handle::null(); + if !succeeded(unsafe { SQLAllocHandle(HandleType::Stmt, self.dbc.as_handle(), &mut raw) }) { + self.error = diagnostic(HandleType::Dbc, self.dbc.as_handle(), "SQLAllocHandle: STMT"); + return None; + } + let statement = raw.as_hstmt(); + let executed = if self.is_sqlsrv() { + let sql = sql.encode_utf16().collect::>(); + unsafe { SQLExecDirectW(statement, sql.as_ptr(), sql.len() as i32) } + } else { + unsafe { SQLExecDirect(statement, sql.as_ptr(), sql.len() as i32) } + }; + if !succeeded(executed) || !succeeded(unsafe { SQLFetch(statement) }) { + self.error = diagnostic(HandleType::Stmt, raw, "SQLExecDirect"); + unsafe { let _ = SQLFreeHandle(HandleType::Stmt, raw); }; + return None; + } + let mut buffer = [0u8; 128]; + let mut indicator = 0isize; + let read = unsafe { + SQLGetData( + statement, + 1, + CDataType::Char, + buffer.as_mut_ptr().cast(), + buffer.len() as isize, + &mut indicator, + ) + }; + unsafe { let _ = SQLFreeHandle(HandleType::Stmt, raw); }; + if !succeeded(read) || indicator == NULL_DATA { + return Some("0".to_string()); + } + let length = usize::try_from(indicator) + .unwrap_or(0) + .min(buffer.len().saturating_sub(1)); + Some(String::from_utf8_lossy(&buffer[..length]).trim().to_string()) + } + + /// Starts a manual transaction by disabling native autocommit when needed. + pub fn begin(&mut self) -> bool { + if self.in_transaction { + return false; + } + if self.auto_commit && !self.set_native_autocommit(false) { + return false; + } + self.in_transaction = true; + true + } + + /// Commits the active transaction and restores configured autocommit. + pub fn commit(&mut self) -> bool { + self.end_transaction(CompletionType::Commit) + } + + /// Rolls back the active transaction and restores configured autocommit. + pub fn rollback(&mut self) -> bool { + self.end_transaction(CompletionType::Rollback) + } + + /// Completes one transaction through the driver manager. + fn end_transaction(&mut self, completion: CompletionType) -> bool { + let result = unsafe { SQLEndTran(HandleType::Dbc, self.dbc.as_handle(), completion) }; + if !succeeded(result) { + self.error = diagnostic(HandleType::Dbc, self.dbc.as_handle(), "SQLEndTran"); + return false; + } + self.in_transaction = false; + !self.auto_commit || self.set_native_autocommit(true) + } + + /// Changes the driver-manager autocommit attribute. + fn set_native_autocommit(&mut self, enabled: bool) -> bool { + let result = unsafe { + SQLSetConnectAttr( + self.dbc, + ConnectionAttribute::AUTOCOMMIT, + (if enabled { SQL_AUTOCOMMIT_ON } else { SQL_AUTOCOMMIT_OFF }) as *mut c_void, + odbc_sys::IS_INTEGER, + ) + }; + if !succeeded(result) { + self.error = diagnostic(HandleType::Dbc, self.dbc.as_handle(), "SQLSetConnectAttr AUTOCOMMIT"); + return false; + } + true + } + + /// Updates PDO_ODBC's writable connection attributes. + pub fn set_attribute(&mut self, attribute: i64, value: i64) -> bool { + #[cfg(feature = "sqlsrv")] + if self.is_sqlsrv() { + return self.set_sqlsrv_attribute(attribute, value); + } + match attribute { + 0 if !self.in_transaction => { + let enabled = value != 0; + if enabled == self.auto_commit || self.set_native_autocommit(enabled) { + self.auto_commit = enabled; + true + } else { + false + } + } + 1001 => { + self.assume_utf8 = value != 0; + true + } + _ => false, + } + } + + /// Reads PDO_ODBC's boolean connection attributes. + pub fn attribute(&self, attribute: i64) -> Option { + #[cfg(feature = "sqlsrv")] + if self.is_sqlsrv() { + return self.sqlsrv_attribute(attribute); + } + match attribute { + 0 => Some(self.auto_commit as i64), + 1001 => Some(self.assume_utf8 as i64), + _ => None, + } + } + + /// Applies one PDO_SQLSRV connection attribute with upstream validation. + #[cfg(feature = "sqlsrv")] + fn set_sqlsrv_attribute(&mut self, attribute: i64, value: i64) -> bool { + let accepted = match attribute { + SQLSRV_ATTR_ENCODING => match value { + SQLSRV_ENCODING_DEFAULT => { + self.sqlsrv_encoding = SQLSRV_ENCODING_UTF8; + true + } + SQLSRV_ENCODING_SYSTEM | SQLSRV_ENCODING_UTF8 => { + self.sqlsrv_encoding = value; + true + } + _ => false, + }, + SQLSRV_ATTR_QUERY_TIMEOUT if value >= 0 => { + self.sqlsrv_query_timeout = value; + true + } + SQLSRV_ATTR_DIRECT_QUERY => { + self.sqlsrv_direct_query = value != 0; + true + } + SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE if value > 0 => { + self.sqlsrv_client_buffer_kb = value; + true + } + SQLSRV_ATTR_FETCHES_NUMERIC_TYPE => { + self.sqlsrv_fetch_numeric = value != 0; + true + } + SQLSRV_ATTR_FETCHES_DATETIME_TYPE => { + self.sqlsrv_fetch_datetime = value != 0; + true + } + SQLSRV_ATTR_FORMAT_DECIMALS => { + self.sqlsrv_format_decimals = value != 0; + true + } + SQLSRV_ATTR_DECIMAL_PLACES => { + self.sqlsrv_decimal_places = if (0..=4).contains(&value) { value } else { -1 }; + true + } + 17 => true, + 20 => { + self.sqlsrv_emulate_prepares = value != 0; + true + } + 21 if matches!(value, 0x2000_0000 | 0x4000_0000) => { + self.sqlsrv_default_str_param = value; + true + } + _ => false, + }; + if accepted { + self.error = ErrorState::default(); + } + accepted + } + + /// Reads one PDO_SQLSRV connection attribute supported by the upstream hook. + #[cfg(feature = "sqlsrv")] + fn sqlsrv_attribute(&self, attribute: i64) -> Option { + match attribute { + SQLSRV_ATTR_ENCODING => Some(self.sqlsrv_encoding), + SQLSRV_ATTR_QUERY_TIMEOUT => Some(self.sqlsrv_query_timeout), + SQLSRV_ATTR_DIRECT_QUERY => Some(self.sqlsrv_direct_query as i64), + SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE => Some(self.sqlsrv_client_buffer_kb), + SQLSRV_ATTR_FETCHES_NUMERIC_TYPE => Some(self.sqlsrv_fetch_numeric as i64), + SQLSRV_ATTR_FETCHES_DATETIME_TYPE => Some(self.sqlsrv_fetch_datetime as i64), + SQLSRV_ATTR_FORMAT_DECIMALS => Some(self.sqlsrv_format_decimals as i64), + SQLSRV_ATTR_DECIMAL_PLACES => Some(self.sqlsrv_decimal_places), + 20 => Some(self.sqlsrv_emulate_prepares as i64), + 21 => Some(self.sqlsrv_default_str_param), + _ => None, + } + } + + /// Writes one PDO_IBM string-valued CLI connection attribute. + #[cfg(feature = "ibm")] + pub fn set_ibm_attribute_text(&mut self, attribute: i64, value: &str) -> bool { + let Ok(attribute) = i32::try_from(attribute) else { + return false; + }; + if !matches!( + attribute, + PDO_IBM_ATTR_INFO_USERID + | PDO_IBM_ATTR_INFO_ACCTSTR + | PDO_IBM_ATTR_INFO_APPLNAME + | PDO_IBM_ATTR_INFO_WRKSTNNAME + | PDO_IBM_ATTR_TRUSTED_CONTEXT_USERID + | PDO_IBM_ATTR_TRUSTED_CONTEXT_PASSWORD + ) { + return false; + } + let native_attribute = ibm_native_connection_attribute(attribute) + .expect("validated PDO_IBM attribute must have a native CLI mapping"); + let result = unsafe { + SQLSetConnectAttrRaw( + self.dbc, + native_attribute, + value.as_ptr().cast_mut().cast(), + value.len() as i32, + ) + }; + if !succeeded(result) { + self.error = diagnostic(HandleType::Dbc, self.dbc.as_handle(), "SQLSetConnectAttr IBM"); + return false; + } + self.error = ErrorState::default(); + true + } + + /// Reads one PDO_IBM string-valued CLI connection attribute. + #[cfg(feature = "ibm")] + pub fn ibm_attribute_text(&mut self, attribute: i64) -> Option { + let attribute = i32::try_from(attribute).ok()?; + if !matches!( + attribute, + PDO_IBM_ATTR_INFO_USERID + | PDO_IBM_ATTR_INFO_ACCTSTR + | PDO_IBM_ATTR_INFO_APPLNAME + | PDO_IBM_ATTR_INFO_WRKSTNNAME + | PDO_IBM_ATTR_TRUSTED_CONTEXT_USERID + ) { + return None; + } + let native_attribute = ibm_native_connection_attribute(attribute) + .expect("validated PDO_IBM attribute must have a native CLI mapping"); + let mut buffer = [0u8; 256]; + let mut length = 0i32; + let result = unsafe { + SQLGetConnectAttrRaw( + self.dbc, + native_attribute, + buffer.as_mut_ptr().cast(), + buffer.len() as i32, + &mut length, + ) + }; + if !succeeded(result) { + self.error = diagnostic(HandleType::Dbc, self.dbc.as_handle(), "SQLGetConnectAttr IBM"); + return None; + } + self.error = ErrorState::default(); + let length = usize::try_from(length).unwrap_or(0).min(buffer.len()); + Some(String::from_utf8_lossy(&buffer[..length]).into_owned()) + } + + /// Reads PDO_IBM's trusted-context enablement flag. + #[cfg(feature = "ibm")] + pub fn ibm_attribute_int(&mut self, attribute: i64) -> Option { + let attribute = i32::try_from(attribute).ok()?; + if attribute != PDO_IBM_ATTR_USE_TRUSTED_CONTEXT { + return None; + } + let native_attribute = ibm_native_connection_attribute(attribute) + .expect("validated PDO_IBM attribute must have a native CLI mapping"); + let mut value = 0i32; + let mut length = 0i32; + let result = unsafe { + SQLGetConnectAttrRaw( + self.dbc, + native_attribute, + (&mut value as *mut i32).cast(), + std::mem::size_of::() as i32, + &mut length, + ) + }; + if !succeeded(result) { + self.error = diagnostic(HandleType::Dbc, self.dbc.as_handle(), "SQLGetConnectAttr IBM"); + return None; + } + self.error = ErrorState::default(); + Some((value != 0) as i64) + } + + /// Reads one textual SQLGetInfo field. + pub fn info(&mut self, info_type: InfoType) -> String { + let mut buffer = [0u8; 256]; + let mut length = 0i16; + let result = unsafe { + SQLGetInfo( + self.dbc, + info_type, + buffer.as_mut_ptr().cast(), + buffer.len() as i16, + &mut length, + ) + }; + if !succeeded(result) { + self.error = diagnostic(HandleType::Dbc, self.dbc.as_handle(), "SQLGetInfo"); + return String::new(); + } + String::from_utf8_lossy(&buffer[..usize::try_from(length).unwrap_or(0).min(buffer.len())]) + .into_owned() + } + + /// Returns the connected DBMS version exposed by `PDO::ATTR_SERVER_VERSION`. + pub fn server_version(&mut self) -> String { + self.info(InfoType::DbmsVer) + } + + /// Returns the extension version string reported by the selected PDO driver. + pub fn client_version(&self) -> String { + match self.flavor { + #[cfg(feature = "odbc")] + CliFlavor::Odbc => "ODBC-unixODBC".to_string(), + #[cfg(feature = "informix")] + CliFlavor::Informix => "1.3.7".to_string(), + #[cfg(feature = "ibm")] + CliFlavor::Ibm => "1.7.0".to_string(), + #[cfg(feature = "sqlsrv")] + CliFlavor::Sqlsrv => "5.13.1".to_string(), + } + } + + /// Returns the connected DBMS name exposed by `PDO::ATTR_SERVER_INFO`. + pub fn server_info(&mut self) -> String { + self.info(InfoType::DbmsName) + } + + /// Returns one PDO_SQLSRV client/server information array field. + #[cfg(feature = "sqlsrv")] + pub fn sqlsrv_info(&mut self, field: i64) -> String { + if !self.is_sqlsrv() { + return String::new(); + } + match field { + 0 => self.query_scalar_text("SELECT DB_NAME()").unwrap_or_default(), + 1 => self.info(InfoType::DbmsVer), + 2 => self.info(InfoType::ServerName), + 3 => self.info(InfoType::DriverName), + 4 => self.info(InfoType::DriverOdbcVer), + 5 => self.info(InfoType::DriverVer), + _ => String::new(), + } + } + + /// Returns the current connection SQLSTATE. + pub fn sqlstate(&self) -> &str { + &self.error.sqlstate + } + + /// Returns the current native ODBC error code. + pub fn errcode(&self) -> i64 { + self.error.native_code + } + + /// Returns the current ODBC diagnostic text. + pub fn errmsg(&self) -> &str { + &self.error.message + } +} + +/// One bound ODBC input value. +#[derive(Clone)] +enum OdbcBind { + Null, + Int(i64), + Double(f64), + Text(Vec), + Binary(Vec), +} + +/// Selects PDO_SQLSRV's native floating-point ODBC types while preserving numeric descriptors. +#[cfg(feature = "sqlsrv")] +fn sqlsrv_double_parameter_types( + flavor: CliFlavor, + bind: &OdbcBind, + described: SqlDataType, +) -> Option<(CDataType, SqlDataType)> { + if !flavor.is_sqlsrv() || !matches!(bind, OdbcBind::Double(_)) { + return None; + } + let sql_type = if matches!( + described, + SqlDataType::NUMERIC + | SqlDataType::DECIMAL + | SqlDataType::FLOAT + | SqlDataType::REAL + | SqlDataType::DOUBLE + ) { + described + } else { + SqlDataType::FLOAT + }; + Some((CDataType::Double, sql_type)) +} + +/// Derives SQLSRV parameter defaults from the PHP value when Always Encrypted metadata is absent. +#[cfg(feature = "sqlsrv")] +fn sqlsrv_parameter_defaults(bind: &OdbcBind, encoding: i64) -> (SqlDataType, usize, i16) { + let wide = encoding != SQLSRV_ENCODING_BINARY && encoding != SQLSRV_ENCODING_SYSTEM; + match bind { + OdbcBind::Null if encoding == SQLSRV_ENCODING_BINARY => { + (SqlDataType::EXT_BINARY, 1, 0) + } + OdbcBind::Null => (SqlDataType::VARCHAR, 0, 0), + OdbcBind::Int(value) if i32::try_from(*value).is_err() => { + (SqlDataType::EXT_BIG_INT, 0, 0) + } + OdbcBind::Int(_) => (SqlDataType::INTEGER, 0, 0), + OdbcBind::Double(_) => (SqlDataType::FLOAT, 0, 0), + OdbcBind::Text(value) if wide => { + let column_size = if value.len().saturating_mul(2) > 8000 { 0 } else { 4000 }; + (SqlDataType::EXT_W_VARCHAR, column_size, 0) + } + OdbcBind::Text(value) => { + let column_size = if value.len() > 8000 { 0 } else { 8000 }; + (SqlDataType::VARCHAR, column_size, 0) + } + OdbcBind::Binary(value) => { + let column_size = if value.len() > 8000 { 0 } else { 8000 }; + (SqlDataType::EXT_VAR_BINARY, column_size, 0) + } + } +} + +/// Maps PDO_SQLSRV cursor constants to the native ODBC cursor used by Microsoft’s driver. +#[cfg(feature = "sqlsrv")] +fn sqlsrv_native_cursor_type(cursor_type: i64) -> Option { + match cursor_type { + 1..=3 => Some(cursor_type), + 42 => Some(0), + _ => None, + } +} + +/// Registration metadata for one CLI input/output parameter. +#[derive(Clone, Copy)] +struct OutputSpec { + max_length: i64, + input_output: bool, + lob: bool, +} + +/// Bounds an output buffer to PDO's declared maximum and returns its input length. +fn prepare_output_buffer(payload: &mut Vec, output: OutputSpec, precision: usize) -> usize { + let input_length = payload.len(); + let capacity = if output.max_length > 0 { + usize::try_from(output.max_length).unwrap_or(usize::MAX) + } else { + precision.max(1) + }; + payload.truncate(capacity); + let input_length = input_length.min(capacity); + payload.resize(capacity, 0); + input_length +} + +/// Renders a SQLSRV emulated-prepare statement using T-SQL literals. +#[cfg(feature = "sqlsrv")] +fn interpolate_sqlsrv( + sql: &str, + order: &[i64], + binds: &[OdbcBind], + national_strings: bool, +) -> Result { + let mut output = String::with_capacity(sql.len() + binds.len() * 8); + let mut marker = 0usize; + let mut chars = sql.chars().peekable(); + let mut quote = None; + while let Some(ch) = chars.next() { + if let Some(active) = quote { + output.push(ch); + if ch == active { + if chars.peek() == Some(&active) { + output.push(chars.next().unwrap_or(active)); + } else { + quote = None; + } + } + continue; + } + if matches!(ch, '\'' | '"' | '[') { + quote = Some(if ch == '[' { ']' } else { ch }); + output.push(ch); + continue; + } + if ch != '?' { + output.push(ch); + continue; + } + let slot = order + .get(marker) + .copied() + .and_then(|slot| usize::try_from(slot).ok()) + .and_then(|slot| slot.checked_sub(1)) + .ok_or_else(|| "Invalid parameter number".to_string())?; + render_sqlsrv_bind( + &mut output, + binds.get(slot).ok_or_else(|| "Invalid parameter number".to_string())?, + national_strings, + ); + marker += 1; + } + if marker != order.len() { + return Err("Invalid parameter number".to_string()); + } + Ok(output) +} + +/// Appends one ODBC bind as the literal syntax used by PDO_SQLSRV emulation. +#[cfg(feature = "sqlsrv")] +fn render_sqlsrv_bind(output: &mut String, value: &OdbcBind, national_strings: bool) { + match value { + OdbcBind::Null => output.push_str("NULL"), + OdbcBind::Int(value) => output.push_str(&value.to_string()), + OdbcBind::Double(value) if value.is_finite() => output.push_str(&value.to_string()), + OdbcBind::Double(_) => output.push_str("NULL"), + OdbcBind::Text(bytes) => { + if national_strings { + output.push('N'); + } + output.push('\''); + for ch in String::from_utf8_lossy(bytes).chars() { + if ch == '\'' { + output.push('\''); + } + output.push(ch); + } + output.push('\''); + } + OdbcBind::Binary(bytes) => { + output.push_str("0x"); + for byte in bytes { + use std::fmt::Write; + let _ = write!(output, "{byte:02X}"); + } + } + } +} + +/// Applies PDO_SQLSRV's decimal leading-zero and money scale formatting. +#[cfg(feature = "sqlsrv")] +fn format_sqlsrv_decimal( + value: Option>, + native_type: &str, + format_decimals: bool, + decimal_places: i64, +) -> Option> { + let decimal_type = native_type.to_ascii_lowercase(); + let money = matches!(decimal_type.as_str(), "money" | "smallmoney"); + let decimal = money || matches!(decimal_type.as_str(), "decimal" | "numeric"); + if (!format_decimals || !decimal) && (decimal_places < 0 || !money) { + return value; + } + let bytes = value?; + let mut text = match String::from_utf8(bytes) { + Ok(text) => text, + Err(error) => return Some(error.into_bytes()), + }; + if format_decimals { + if text.starts_with('.') { + text.insert(0, '0'); + } else if text.starts_with("-.") { + text.insert(1, '0'); + } + } + if decimal_places >= 0 + && money + { + if let Ok(number) = text.parse::() { + text = format!("{number:.precision$}", precision = decimal_places as usize); + } + } + Some(text.into_bytes()) +} + +/// Reads one native-endian `u16` from Microsoft's classification blob. +#[cfg(feature = "sqlsrv")] +fn classification_u16(blob: &[u8], offset: &mut usize) -> Result { + let end = offset.saturating_add(2); + let bytes: [u8; 2] = blob + .get(*offset..end) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| "Truncated SQL Server data-classification metadata".to_string())?; + *offset = end; + Ok(u16::from_ne_bytes(bytes)) +} + +/// Reads one native-endian `i32` from Microsoft's classification blob. +#[cfg(feature = "sqlsrv")] +fn classification_i32(blob: &[u8], offset: &mut usize) -> Result { + let end = offset.saturating_add(4); + let bytes: [u8; 4] = blob + .get(*offset..end) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| "Truncated SQL Server data-classification metadata".to_string())?; + *offset = end; + Ok(i32::from_ne_bytes(bytes)) +} + +/// Reads one length-prefixed UTF-16 name or identifier from the classification blob. +#[cfg(feature = "sqlsrv")] +fn classification_utf16(blob: &[u8], offset: &mut usize) -> Result { + let units = usize::from( + *blob + .get(*offset) + .ok_or_else(|| "Truncated SQL Server data-classification metadata".to_string())?, + ); + *offset = offset.saturating_add(1); + let byte_len = units.saturating_mul(2); + let end = offset.saturating_add(byte_len); + let bytes = blob + .get(*offset..end) + .ok_or_else(|| "Truncated SQL Server data-classification metadata".to_string())?; + let utf16 = bytes + .chunks_exact(2) + .map(|unit| u16::from_ne_bytes([unit[0], unit[1]])) + .collect::>(); + *offset = end; + Ok(String::from_utf16_lossy(&utf16)) +} + +/// Parses the ODBC Driver 17.2+ sensitivity-label blob into PDO-facing columns. +#[cfg(feature = "sqlsrv")] +fn parse_sqlsrv_classification_blob( + blob: &[u8], + rank_available: bool, +) -> Result { + let mut offset = 0usize; + let label_count = usize::from(classification_u16(blob, &mut offset)?); + let mut labels = Vec::with_capacity(label_count); + for _ in 0..label_count { + labels.push(( + classification_utf16(blob, &mut offset)?, + classification_utf16(blob, &mut offset)?, + )); + } + let info_count = usize::from(classification_u16(blob, &mut offset)?); + let mut information_types = Vec::with_capacity(info_count); + for _ in 0..info_count { + information_types.push(( + classification_utf16(blob, &mut offset)?, + classification_utf16(blob, &mut offset)?, + )); + } + let query_rank = if rank_available { + Some(classification_i32(blob, &mut offset)?) + } else { + None + }; + let column_count = usize::from(classification_u16(blob, &mut offset)?); + let mut columns = Vec::with_capacity(column_count); + for _ in 0..column_count { + let pair_count = usize::from(classification_u16(blob, &mut offset)?); + let mut pairs = Vec::with_capacity(pair_count); + for _ in 0..pair_count { + let label_index = usize::from(classification_u16(blob, &mut offset)?); + let information_index = usize::from(classification_u16(blob, &mut offset)?); + let rank = if rank_available { + Some(classification_i32(blob, &mut offset)?) + } else { + None + }; + let (label_name, label_id) = labels + .get(label_index) + .cloned() + .ok_or_else(|| "Invalid SQL Server sensitivity-label index".to_string())?; + let (information_name, information_id) = information_types + .get(information_index) + .cloned() + .ok_or_else(|| "Invalid SQL Server information-type index".to_string())?; + pairs.push(SqlsrvClassificationPair { + label_name, + label_id, + information_name, + information_id, + rank, + }); + } + columns.push(pairs); + } + if offset != blob.len() { + return Err("Unexpected trailing SQL Server data-classification metadata".to_string()); + } + Ok(SqlsrvClassification { + query_rank, + columns, + }) +} + +/// Reads one text descriptor attribute without making unsupported metadata fatal. +fn column_text_attribute(statement: HStmt, column: u16, attribute: Desc) -> Option { + let mut buffer = [0u8; 256]; + let mut length = 0i16; + let mut numeric = 0isize; + let result = unsafe { + SQLColAttribute( + statement, + column, + attribute, + buffer.as_mut_ptr().cast(), + buffer.len() as i16, + &mut length, + &mut numeric, + ) + }; + if !succeeded(result) { + return None; + } + let length = usize::try_from(length).unwrap_or(0).min(buffer.len()); + Some(String::from_utf8_lossy(&buffer[..length]).into_owned()) +} + +/// Reads one UTF-16 descriptor attribute for PDO_SQLSRV Unicode metadata. +#[cfg(feature = "sqlsrv")] +fn column_text_attribute_w(statement: HStmt, column: u16, attribute: Desc) -> Option { + let mut buffer = [0u16; 256]; + let mut length = 0i16; + let mut numeric = 0isize; + let result = unsafe { + SQLColAttributeW( + statement, + column, + attribute, + buffer.as_mut_ptr().cast(), + (buffer.len() * std::mem::size_of::()) as i16, + &mut length, + &mut numeric, + ) + }; + if !succeeded(result) { + return None; + } + let units = usize::try_from(length) + .unwrap_or(0) + .checked_div(std::mem::size_of::()) + .unwrap_or(0) + .min(buffer.len()); + Some(String::from_utf16_lossy(&buffer[..units])) +} + +/// Reads one numeric descriptor attribute without making unsupported metadata fatal. +fn column_numeric_attribute(statement: HStmt, column: u16, attribute: Desc) -> Option { + let mut length = 0i16; + let mut numeric = 0isize; + let result = unsafe { + SQLColAttribute( + statement, + column, + attribute, + ptr::null_mut(), + 0, + &mut length, + &mut numeric, + ) + }; + succeeded(result).then_some(numeric) +} + +/// Identifies the two Informix UDT names that PECL maps to `PDO::PARAM_LOB` metadata. +fn informix_metadata_is_lob(native_type: &str) -> bool { + let native_type = native_type.to_ascii_uppercase(); + native_type == "BLOB" + || native_type == "CLOB" + || native_type.ends_with("_UDT_BLOB") + || native_type.ends_with("_UDT_CLOB") +} + +/// Reproduces PDO_IBM 1.7.0's metadata switch, including BOOLEAN/BIT fallthrough. +#[cfg(feature = "ibm")] +fn ibm_metadata_is_lob(data_type: i16) -> bool { + matches!(data_type, -7 | 16 | -2 | -3 | -4 | -98 | -99 | -370) +} + +/// Completed CLI output value copied before its native execution buffer expires. +#[derive(Clone)] +pub(crate) struct OdbcOutputValue { + pub(crate) data: Option>, + pub(crate) lob: bool, + pub(crate) numeric: bool, +} + +/// One materialized ODBC result column. +struct OdbcColumn { + name: String, + wide: bool, + lob: bool, + metadata_pdo_lob: bool, + len: i64, + precision: i64, + scale: i64, + table: String, + native_type: String, + flags: i64, + #[cfg(feature = "sqlsrv")] + data_type: i16, +} + +/// One label/information-type pair returned for a classified result column. +#[cfg(feature = "sqlsrv")] +struct SqlsrvClassificationPair { + label_name: String, + label_id: String, + information_name: String, + information_id: String, + rank: Option, +} + +/// Parsed SQLSRV sensitivity metadata shared by `getColumnMeta()` calls. +#[cfg(feature = "sqlsrv")] +struct SqlsrvClassification { + query_rank: Option, + columns: Vec>, +} + +/// Prepared ODBC statement retaining its native handle across result sets. +pub struct OdbcStmt { + pub conn_id: i64, + flavor: CliFlavor, + stmt: HStmt, + named_map: HashMap, + order: Vec, + binds: Vec, + bound: Vec, + indicators: Vec, + output_specs: Vec>, + output_values: Vec>, + columns: Vec, + rows: Vec>>>, + cursor: isize, + executed: bool, + row_count: i64, + assume_utf8: bool, + pub sent_sql: String, + error: ErrorState, + is_insert: bool, + #[cfg(feature = "sqlsrv")] + translated_sql: String, + #[cfg(feature = "sqlsrv")] + sqlsrv_direct_query: bool, + #[cfg(feature = "sqlsrv")] + sqlsrv_emulated: bool, + #[cfg(feature = "sqlsrv")] + sqlsrv_encoding: i64, + #[cfg(feature = "sqlsrv")] + sqlsrv_query_timeout: i64, + #[cfg(feature = "sqlsrv")] + sqlsrv_cursor_type: i64, + #[cfg(feature = "sqlsrv")] + sqlsrv_client_buffer_kb: i64, + #[cfg(feature = "sqlsrv")] + sqlsrv_fetch_numeric: bool, + #[cfg(feature = "sqlsrv")] + sqlsrv_fetch_datetime: bool, + #[cfg(feature = "sqlsrv")] + sqlsrv_format_decimals: bool, + #[cfg(feature = "sqlsrv")] + sqlsrv_decimal_places: i64, + #[cfg(feature = "sqlsrv")] + sqlsrv_data_classification: bool, + #[cfg(feature = "sqlsrv")] + sqlsrv_classification: Option, + #[cfg(feature = "sqlsrv")] + sqlsrv_classification_error: Option, +} + +unsafe impl Send for OdbcStmt {} + +impl Drop for OdbcStmt { + /// Closes and frees the native statement handle. + fn drop(&mut self) { + unsafe { + let _ = SQLCloseCursor(self.stmt); + let _ = SQLFreeHandle(HandleType::Stmt, self.stmt.as_handle()); + } + } +} + +impl OdbcStmt { + /// Allocates and prepares an ODBC statement with PDO placeholder normalization. + pub fn new( + connection: &mut OdbcConn, + conn_id: i64, + sql: &str, + mode: i64, + ) -> Result { + let (translated, named_map, order, mixed) = crate::my::translate_pdo_placeholders(sql); + if mixed { + return Err("Invalid parameter number: mixed named and positional parameters".to_string()); + } + let mut raw = Handle::null(); + if !succeeded(unsafe { SQLAllocHandle(HandleType::Stmt, connection.dbc.as_handle(), &mut raw) }) { + connection.error = diagnostic(HandleType::Dbc, connection.dbc.as_handle(), "SQLAllocHandle: STMT"); + return Err(connection.error.message.clone()); + } + let stmt = raw.as_hstmt(); + #[cfg(feature = "sqlsrv")] + let sqlsrv_cursor_type = if connection.is_sqlsrv() { + let requested = (mode >> 8) & 0xff; + if requested != 0 + && ((mode & 2) == 0 || sqlsrv_native_cursor_type(requested).is_none()) + { + let message = "An invalid SQLSRV cursor option was designated.".to_string(); + unsafe { + let _ = SQLFreeHandle(HandleType::Stmt, raw); + } + connection.error = ErrorState { + sqlstate: "IMSSP".to_string(), + native_code: 0, + message: message.clone(), + }; + return Err(message); + } + if requested != 0 { + requested + } else if (mode & 2) != 0 { + 3 + } else { + 0 + } + } else { + 0 + }; + #[cfg(feature = "sqlsrv")] + let sqlsrv_native_cursor = connection + .is_sqlsrv() + .then(|| sqlsrv_native_cursor_type(sqlsrv_cursor_type)) + .flatten(); + #[cfg(not(feature = "sqlsrv"))] + let sqlsrv_native_cursor: Option = None; + if (mode & 2) != 0 { + let (attribute, value) = if let Some(cursor_type) = sqlsrv_native_cursor { + (StatementAttribute::CursorType, cursor_type as isize) + } else { + (StatementAttribute::CursorScrollable, 1isize) + }; + let configured = unsafe { + SQLSetStmtAttr(stmt, attribute, value as *mut c_void, 0) + }; + if !succeeded(configured) { + let error = diagnostic(HandleType::Stmt, raw, "SQLSetStmtAttr: scrollable cursor"); + unsafe { let _ = SQLFreeHandle(HandleType::Stmt, raw); }; + connection.error = error.clone(); + return Err(error.message); + } + } + #[cfg(feature = "sqlsrv")] + let direct_sqlsrv = connection.is_sqlsrv() + && (connection.sqlsrv_direct_query || (mode & 4) != 0); + #[cfg(not(feature = "sqlsrv"))] + let direct_sqlsrv = false; + if !direct_sqlsrv { + let prepared = if connection.is_sqlsrv() { + let wide = translated.encode_utf16().collect::>(); + unsafe { SQLPrepareW(stmt, wide.as_ptr(), wide.len() as i32) } + } else { + unsafe { SQLPrepare(stmt, translated.as_ptr(), translated.len() as i32) } + }; + if !succeeded(prepared) { + let error = diagnostic(HandleType::Stmt, raw, "SQLPrepare"); + unsafe { let _ = SQLFreeHandle(HandleType::Stmt, raw); }; + connection.error = error.clone(); + return Err(error.message); + } + } + let slots = order.iter().copied().max().unwrap_or(0).max(0) as usize; + let mut native_params = 0i16; + if !direct_sqlsrv + && succeeded(unsafe { SQLNumParams(stmt, &mut native_params) }) + && native_params as usize != order.len() + { + unsafe { let _ = SQLFreeHandle(HandleType::Stmt, raw); }; + return Err("Invalid parameter number: number of bound variables does not match number of tokens".to_string()); + } + Ok(Self { + conn_id, + flavor: connection.flavor, + stmt, + named_map, + order, + binds: vec![OdbcBind::Null; slots], + bound: vec![false; slots], + indicators: vec![NULL_DATA; slots], + output_specs: vec![None; slots], + output_values: vec![None; slots], + columns: Vec::new(), + rows: Vec::new(), + cursor: -1, + executed: false, + row_count: 0, + assume_utf8: connection.assume_utf8, + sent_sql: String::new(), + error: ErrorState::default(), + is_insert: translated + .trim_start() + .to_ascii_lowercase() + .starts_with("insert"), + #[cfg(feature = "sqlsrv")] + translated_sql: translated, + #[cfg(feature = "sqlsrv")] + sqlsrv_direct_query: direct_sqlsrv, + #[cfg(feature = "sqlsrv")] + sqlsrv_emulated: connection.sqlsrv_emulate_prepares || (mode & 1) != 0, + #[cfg(feature = "sqlsrv")] + sqlsrv_encoding: SQLSRV_ENCODING_DEFAULT, + #[cfg(feature = "sqlsrv")] + sqlsrv_query_timeout: connection.sqlsrv_query_timeout, + #[cfg(feature = "sqlsrv")] + sqlsrv_cursor_type, + #[cfg(feature = "sqlsrv")] + sqlsrv_client_buffer_kb: connection.sqlsrv_client_buffer_kb, + #[cfg(feature = "sqlsrv")] + sqlsrv_fetch_numeric: connection.sqlsrv_fetch_numeric, + #[cfg(feature = "sqlsrv")] + sqlsrv_fetch_datetime: connection.sqlsrv_fetch_datetime, + #[cfg(feature = "sqlsrv")] + sqlsrv_format_decimals: connection.sqlsrv_format_decimals, + #[cfg(feature = "sqlsrv")] + sqlsrv_decimal_places: connection.sqlsrv_decimal_places, + #[cfg(feature = "sqlsrv")] + sqlsrv_data_classification: false, + #[cfg(feature = "sqlsrv")] + sqlsrv_classification: None, + #[cfg(feature = "sqlsrv")] + sqlsrv_classification_error: None, + }) + } + + /// Resolves a named placeholder to its one-based PDO slot. + pub fn parameter_index(&self, name: &str) -> i64 { + self.named_map.get(name.trim_start_matches(':')).copied().unwrap_or(-1) + } + + /// Stores one bind value in a one-based slot. + fn bind(&mut self, index: i64, value: OdbcBind) -> bool { + let Some(slot) = usize::try_from(index).ok().and_then(|index| index.checked_sub(1)) else { + return false; + }; + if slot >= self.binds.len() { + return false; + } + self.binds[slot] = value; + self.bound[slot] = true; + true + } + + /// Binds one integer value. + pub fn bind_int(&mut self, index: i64, value: i64) -> bool { + self.bind(index, OdbcBind::Int(value)) + } + + /// Binds one floating-point value. + pub fn bind_double(&mut self, index: i64, value: f64) -> bool { + self.bind(index, OdbcBind::Double(value)) + } + + /// Binds one text value. + pub fn bind_text(&mut self, index: i64, value: Vec) -> bool { + self.bind(index, OdbcBind::Text(value)) + } + + /// Binds one binary value. + pub fn bind_blob(&mut self, index: i64, value: Vec) -> bool { + self.bind(index, OdbcBind::Binary(value)) + } + + /// Binds SQL NULL. + pub fn bind_null(&mut self, index: i64) -> bool { + self.bind(index, OdbcBind::Null) + } + + /// Registers an input/output buffer for a scalar CLI parameter. + pub fn bind_output(&mut self, index: i64, pdo_type: i64, max_length: i64) -> i64 { + let Some(slot) = usize::try_from(index).ok().and_then(|index| index.checked_sub(1)) else { + return 0; + }; + if slot >= self.output_specs.len() { + return 0; + } + let base_type = pdo_type & 0xFFFF; + // PDO_INFORMIX explicitly forces LOB parameters back to input-only. + if base_type == 3 && !self.flavor.is_sqlsrv() { + #[cfg(feature = "odbc")] + if self.flavor == CliFlavor::Odbc { + self.error = ErrorState { + sqlstate: "HY000".to_string(), + native_code: 0, + message: "Can't bind a lob for output".to_string(), + }; + return -1; + } + self.output_specs[slot] = None; + return 1; + } + self.output_specs[slot] = Some(OutputSpec { + max_length, + input_output: (pdo_type & 0x8000_0000) != 0, + lob: base_type == 3, + }); + 1 + } + + /// Returns a completed scalar output parameter, if the slot was output-bound. + pub fn output_value(&self, index: i64) -> Option<&OdbcOutputValue> { + usize::try_from(index) + .ok() + .and_then(|index| index.checked_sub(1)) + .and_then(|slot| self.output_values.get(slot)) + .and_then(Option::as_ref) + } + + /// Resets execution/cursor state while preserving binds. + pub fn reset(&mut self) { + unsafe { let _ = SQLCloseCursor(self.stmt); }; + self.columns.clear(); + self.rows.clear(); + self.cursor = -1; + self.executed = false; + self.row_count = 0; + self.output_values.fill(None); + } + + /// Clears execution state and all bound values. + pub fn clear_bindings(&mut self) { + self.reset(); + self.binds.fill(OdbcBind::Null); + self.bound.fill(false); + self.output_specs.fill(None); + } + + /// Reports whether the statement still needs execution. + pub fn needs_execute(&self) -> bool { + !self.executed + } + + /// Binds all occurrences and executes the prepared native statement. + pub fn execute(&mut self, connection: &mut OdbcConn) -> Result<(), String> { + if self.bound.iter().any(|bound| !bound) { + self.error = ErrorState { + sqlstate: "HY093".to_string(), + native_code: 0, + message: "Invalid parameter number: number of bound variables does not match number of tokens".to_string(), + }; + return Err(self.error.message.clone()); + } + unsafe { + let _ = SQLCloseCursor(self.stmt); + let _ = SQLFreeStmt(self.stmt, FreeStmtOption::ResetParams); + } + #[cfg(feature = "sqlsrv")] + if self.flavor.is_sqlsrv() && self.sqlsrv_emulated { + return self.execute_sqlsrv_emulated(connection); + } + #[cfg(feature = "sqlsrv")] + if self.flavor.is_sqlsrv() && self.sqlsrv_query_timeout > 0 { + let timeout = self.sqlsrv_query_timeout as isize as *mut c_void; + let configured = unsafe { + SQLSetStmtAttr(self.stmt, StatementAttribute::QueryTimeout, timeout, 0) + }; + if !succeeded(configured) { + self.error = diagnostic( + HandleType::Stmt, + self.stmt.as_handle(), + "SQLSetStmtAttr: SQL_ATTR_QUERY_TIMEOUT", + ); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + } + let mut payloads = Vec::with_capacity(self.order.len()); + let mut native_doubles = Vec::with_capacity(self.order.len()); + let mut descriptors = Vec::with_capacity(self.order.len()); + for (occurrence, slot) in self.order.iter().enumerate() { + let slot = usize::try_from(*slot).ok().and_then(|slot| slot.checked_sub(1)).unwrap_or(0); + #[cfg(feature = "sqlsrv")] + let sqlsrv_defaults = self.flavor.is_sqlsrv().then(|| { + sqlsrv_parameter_defaults(&self.binds[slot], self.sqlsrv_encoding) + }); + #[cfg(not(feature = "sqlsrv"))] + let sqlsrv_defaults: Option<(SqlDataType, usize, i16)> = None; + let (fallback_type, initial_size, initial_scale) = + sqlsrv_defaults.unwrap_or_else(|| match &self.binds[slot] { + OdbcBind::Int(_) => (SqlDataType::INTEGER, 4000, 5), + OdbcBind::Binary(value) => { + (SqlDataType::EXT_LONG_VAR_BINARY, value.len().max(4000), 5) + } + OdbcBind::Text(value) => { + (SqlDataType::EXT_LONG_VARCHAR, value.len().max(4000), 5) + } + _ => (SqlDataType::EXT_LONG_VARCHAR, 4000, 5), + }); + let mut sql_type = fallback_type; + let mut column_size = initial_size; + let mut scale = initial_scale; + if !self.flavor.is_sqlsrv() { + let mut nullable = Nullability::NULLABLE; + let described = unsafe { + SQLDescribeParam( + self.stmt, + occurrence as u16 + 1, + &mut sql_type, + &mut column_size, + &mut scale, + &mut nullable, + ) + }; + if !succeeded(described) { + sql_type = fallback_type; + column_size = initial_size; + scale = initial_scale; + } + } + #[cfg(feature = "sqlsrv")] + let native_double = + sqlsrv_double_parameter_types(self.flavor, &self.binds[slot], sql_type); + #[cfg(not(feature = "sqlsrv"))] + let native_double: Option<(CDataType, SqlDataType)> = None; + if let Some((_, native_sql_type)) = native_double { + if native_sql_type != sql_type { + column_size = 0; + scale = 0; + } + sql_type = native_sql_type; + } + let wide = if self.flavor.is_sqlsrv() { + #[cfg(feature = "sqlsrv")] + { + self.sqlsrv_encoding != SQLSRV_ENCODING_BINARY + && self.sqlsrv_encoding != SQLSRV_ENCODING_SYSTEM + } + #[cfg(not(feature = "sqlsrv"))] + false + } else { + self.assume_utf8 + && matches!( + sql_type, + SqlDataType::EXT_W_CHAR + | SqlDataType::EXT_W_VARCHAR + | SqlDataType::EXT_W_LONG_VARCHAR + ) + }; + let (mut payload, c_type, indicator) = match (&self.binds[slot], native_double) { + (OdbcBind::Double(_), Some((c_type, _))) => { + (Vec::new(), c_type, std::mem::size_of::() as isize) + } + (OdbcBind::Null, _) => (Vec::new(), CDataType::Char, NULL_DATA), + (OdbcBind::Int(value), _) => { + let text = value.to_string(); + (text.into_bytes(), CDataType::Char, 0) + } + (OdbcBind::Double(value), _) => { + let text = value.to_string(); + (text.into_bytes(), CDataType::Char, 0) + } + (OdbcBind::Text(value), _) if wide => { + let payload = String::from_utf8(value.clone()).map_or_else( + |_| value.clone(), + |text| { + text.encode_utf16() + .flat_map(u16::to_ne_bytes) + .collect::>() + }, + ); + (payload, CDataType::WChar, 0) + } + (OdbcBind::Text(value), _) => (value.clone(), CDataType::Char, 0), + (OdbcBind::Binary(value), _) => (value.clone(), CDataType::Binary, 0), + }; + let mut input_length = native_double + .map_or(payload.len(), |_| std::mem::size_of::()); + if let Some(output) = self.output_specs[slot].filter(|_| native_double.is_none()) { + input_length = prepare_output_buffer(&mut payload, output, column_size); + } + let indicator = if indicator == NULL_DATA { + NULL_DATA + } else { + input_length as isize + }; + payloads.push(payload); + native_doubles.push(native_double.map(|_| match self.binds[slot] { + OdbcBind::Double(value) => Box::new(value), + _ => unreachable!("native double descriptors only accompany double binds"), + })); + descriptors.push(( + c_type, + sql_type, + column_size, + scale, + indicator, + wide, + native_double.is_some(), + )); + } + self.indicators.clear(); + self.indicators.extend(descriptors.iter().map(|descriptor| descriptor.4)); + for (occurrence, (c_type, sql_type, column_size, scale, _, _, native_double)) in + descriptors.iter().copied().enumerate() + { + let payload = &mut payloads[occurrence]; + let slot = usize::try_from(self.order[occurrence]) + .ok() + .and_then(|slot| slot.checked_sub(1)) + .unwrap_or(0); + let pointer = if native_double { + native_doubles[occurrence] + .as_deref_mut() + .map_or(ptr::null_mut(), |value| (value as *mut f64).cast()) + } else if self.indicators[occurrence] == NULL_DATA + && self.output_specs[slot].is_none() + { + ptr::null_mut() + } else { + payload.as_mut_ptr().cast() + }; + let parameter_type = match self.output_specs[slot] { + Some(output) if output.input_output => ParamType::InputOutput, + Some(_) => ParamType::Output, + None => ParamType::Input, + }; + let result = unsafe { + SQLBindParameter( + self.stmt, + occurrence as u16 + 1, + parameter_type, + c_type, + sql_type, + column_size, + scale, + pointer, + if native_double { + std::mem::size_of::() as isize + } else { + payload.len() as isize + }, + &mut self.indicators[occurrence], + ) + }; + if !succeeded(result) { + self.error = diagnostic(HandleType::Stmt, self.stmt.as_handle(), "SQLBindParameter"); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + } + #[cfg(feature = "sqlsrv")] + let result = if self.flavor.is_sqlsrv() && self.sqlsrv_direct_query { + let sql = self.translated_sql.encode_utf16().collect::>(); + unsafe { SQLExecDirectW(self.stmt, sql.as_ptr(), sql.len() as i32) } + } else { + unsafe { SQLExecute(self.stmt) } + }; + #[cfg(not(feature = "sqlsrv"))] + let result = unsafe { SQLExecute(self.stmt) }; + if result != SqlReturn::NO_DATA && !succeeded(result) { + self.error = diagnostic(HandleType::Stmt, self.stmt.as_handle(), "SQLExecute"); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + let execution_info = if result == SqlReturn::SUCCESS_WITH_INFO && connection.is_odbc() { + Some(diagnostic( + HandleType::Stmt, + self.stmt.as_handle(), + "SQLExecute", + )) + } else { + None + }; + self.output_values.fill(None); + for (occurrence, slot) in self.order.iter().copied().enumerate() { + let Some(slot) = usize::try_from(slot).ok().and_then(|slot| slot.checked_sub(1)) else { + continue; + }; + if self.output_specs[slot].is_none() { + continue; + } + let indicator = self.indicators[occurrence]; + let data = if indicator == NULL_DATA { + None + } else if descriptors[occurrence].6 { + Some( + native_doubles[occurrence] + .as_deref() + .map_or(0.0, |value| *value) + .to_string() + .into_bytes(), + ) + } else { + let length = usize::try_from(indicator) + .unwrap_or(0) + .min(payloads[occurrence].len()); + let bytes = &payloads[occurrence][..length]; + if descriptors[occurrence].5 { + let units = bytes + .chunks_exact(2) + .map(|bytes| u16::from_ne_bytes([bytes[0], bytes[1]])); + Some(String::from_utf16_lossy(&units.collect::>()).into_bytes()) + } else { + Some(bytes.to_vec()) + } + }; + self.output_values[slot] = Some(OdbcOutputValue { + data, + lob: self.output_specs[slot].is_some_and(|output| output.lob), + numeric: matches!(descriptors[occurrence].1.0, -7 | 4 | 5 | 16), + }); + } + self.sent_sql.clear(); + self.materialize_current_result(connection)?; + if connection.is_ibm() { + connection.refresh_ibm_ids_last_insert_id(self.stmt); + } + if self.is_insert && connection.is_informix() { + connection.refresh_informix_last_insert_id(); + } + self.executed = true; + if let Some(warning) = execution_info { + self.error = warning.clone(); + connection.error = warning; + } else { + self.error = ErrorState::default(); + connection.error = ErrorState::default(); + } + Ok(()) + } + + /// Executes SQLSRV's client-side emulated-prepare path through `SQLExecDirectW`. + #[cfg(feature = "sqlsrv")] + fn execute_sqlsrv_emulated(&mut self, connection: &mut OdbcConn) -> Result<(), String> { + if self.output_specs.iter().any(Option::is_some) { + self.error = ErrorState { + sqlstate: "IMSSP".to_string(), + native_code: -82, + message: "Output parameters are not supported with emulated prepares".to_string(), + }; + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + let national = connection.sqlsrv_default_str_param == 0x4000_0000 + || connection.sqlsrv_encoding == SQLSRV_ENCODING_UTF8; + self.sent_sql = interpolate_sqlsrv( + &self.translated_sql, + &self.order, + &self.binds, + national, + )?; + if self.sqlsrv_query_timeout > 0 { + let configured = unsafe { + SQLSetStmtAttr( + self.stmt, + StatementAttribute::QueryTimeout, + self.sqlsrv_query_timeout as isize as *mut c_void, + 0, + ) + }; + if !succeeded(configured) { + self.error = diagnostic( + HandleType::Stmt, + self.stmt.as_handle(), + "SQLSetStmtAttr: SQL_ATTR_QUERY_TIMEOUT", + ); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + } + let sql = self.sent_sql.encode_utf16().collect::>(); + let result = unsafe { SQLExecDirectW(self.stmt, sql.as_ptr(), sql.len() as i32) }; + if result != SqlReturn::NO_DATA && !succeeded(result) { + self.error = diagnostic(HandleType::Stmt, self.stmt.as_handle(), "SQLExecDirectW"); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + self.output_values.fill(None); + self.materialize_current_result(connection)?; + self.executed = true; + self.error = ErrorState::default(); + connection.error = ErrorState::default(); + Ok(()) + } + + /// Describes and materializes the active native result set. + fn materialize_current_result(&mut self, connection: &mut OdbcConn) -> Result<(), String> { + self.columns.clear(); + self.rows.clear(); + self.cursor = -1; + #[cfg(feature = "sqlsrv")] + { + self.sqlsrv_classification = None; + self.sqlsrv_classification_error = None; + } + let sqlsrv = connection.is_sqlsrv(); + let mut count = 0i16; + if !succeeded(unsafe { SQLNumResultCols(self.stmt, &mut count) }) { + self.error = diagnostic(HandleType::Stmt, self.stmt.as_handle(), "SQLNumResultCols"); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + if count == 0 { + let mut row_count = -1; + if succeeded(unsafe { SQLRowCount(self.stmt, &mut row_count) }) { + self.row_count = if row_count < 0 { 0 } else { row_count as i64 }; + } else { + self.row_count = 0; + } + connection.changes = self.row_count; + return Ok(()); + } + for index in 1..=count { + let mut name = [0u8; 256]; + #[cfg(feature = "sqlsrv")] + let mut wide_name = [0u16; 256]; + let mut name_len = 0i16; + let mut data_type = SqlDataType::UNKNOWN_TYPE; + let mut size = 0usize; + let mut scale = 0i16; + let mut nullable = Nullability::UNKNOWN; + #[cfg(feature = "sqlsrv")] + let result = if sqlsrv { + unsafe { + SQLDescribeColW( + self.stmt, + index as u16, + wide_name.as_mut_ptr(), + wide_name.len() as i16, + &mut name_len, + &mut data_type, + &mut size, + &mut scale, + &mut nullable, + ) + } + } else { + unsafe { + SQLDescribeCol( + self.stmt, + index as u16, + name.as_mut_ptr(), + name.len() as i16, + &mut name_len, + &mut data_type, + &mut size, + &mut scale, + &mut nullable, + ) + } + }; + #[cfg(not(feature = "sqlsrv"))] + let result = unsafe { + SQLDescribeCol( + self.stmt, + index as u16, + name.as_mut_ptr(), + name.len() as i16, + &mut name_len, + &mut data_type, + &mut size, + &mut scale, + &mut nullable, + ) + }; + if !succeeded(result) { + self.error = diagnostic(HandleType::Stmt, self.stmt.as_handle(), "SQLDescribeCol"); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + let informix = connection.is_informix(); + let ibm = connection.is_ibm(); + let native_type = if sqlsrv { + #[cfg(feature = "sqlsrv")] + { + column_text_attribute_w(self.stmt, index as u16, Desc::TypeName) + .unwrap_or_default() + } + #[cfg(not(feature = "sqlsrv"))] + String::new() + } else if informix || ibm { + column_text_attribute(self.stmt, index as u16, Desc::TypeName).unwrap_or_default() + } else { + String::new() + }; + let informix_lob = informix + && (matches!( + data_type, + SqlDataType::EXT_LONG_VARCHAR + | SqlDataType::EXT_BINARY + | SqlDataType::EXT_VAR_BINARY + | SqlDataType::EXT_LONG_VAR_BINARY + ) || data_type.0 == 17); + let ibm_lob = ibm + && matches!(data_type.0, -2 | -3 | -4 | -98 | -99 | -370); + let lob = informix_lob || ibm_lob; + let metadata_pdo_lob = (informix && informix_metadata_is_lob(&native_type)) + || (ibm && { + #[cfg(feature = "ibm")] + { + ibm_metadata_is_lob(data_type.0) + } + #[cfg(not(feature = "ibm"))] + false + }); + let mut flags = 0; + if nullable == Nullability::NO_NULLS { + flags |= 1; + } + if (informix || ibm) + && column_numeric_attribute(self.stmt, index as u16, Desc::Unsigned) + .is_some_and(|value| value != 0) + { + flags |= 2; + } + if (informix || ibm) + && column_numeric_attribute(self.stmt, index as u16, Desc::AutoUniqueValue) + .is_some_and(|value| value != 0) + { + flags |= 4; + } + #[cfg(feature = "sqlsrv")] + let column_name = if sqlsrv { + String::from_utf16_lossy( + &wide_name[..usize::try_from(name_len) + .unwrap_or(0) + .min(wide_name.len())], + ) + } else { + String::from_utf8_lossy( + &name[..usize::try_from(name_len).unwrap_or(0).min(name.len())], + ) + .into_owned() + }; + #[cfg(not(feature = "sqlsrv"))] + let column_name = String::from_utf8_lossy( + &name[..usize::try_from(name_len).unwrap_or(0).min(name.len())], + ) + .into_owned(); + self.columns.push(OdbcColumn { + name: column_name, + wide: if sqlsrv { + #[cfg(feature = "sqlsrv")] + { + self.sqlsrv_encoding != SQLSRV_ENCODING_BINARY + && self.sqlsrv_encoding != SQLSRV_ENCODING_SYSTEM + && !matches!( + data_type, + SqlDataType::EXT_BINARY + | SqlDataType::EXT_VAR_BINARY + | SqlDataType::EXT_LONG_VAR_BINARY + ) + } + #[cfg(not(feature = "sqlsrv"))] + false + } else { + self.assume_utf8 + && matches!( + data_type, + SqlDataType::EXT_W_CHAR + | SqlDataType::EXT_W_VARCHAR + | SqlDataType::EXT_W_LONG_VARCHAR + ) + }, + lob, + metadata_pdo_lob, + len: i64::try_from(size).unwrap_or(i64::MAX), + precision: i64::from(scale), + scale: i64::from(scale), + table: if informix || ibm || sqlsrv { + if sqlsrv { + #[cfg(feature = "sqlsrv")] + { + column_text_attribute_w( + self.stmt, + index as u16, + Desc::BaseTableName, + ) + .unwrap_or_default() + } + #[cfg(not(feature = "sqlsrv"))] + String::new() + } else { + column_text_attribute(self.stmt, index as u16, Desc::BaseTableName) + .unwrap_or_default() + } + } else { + String::new() + }, + native_type, + flags, + #[cfg(feature = "sqlsrv")] + data_type: data_type.0, + }); + } + #[cfg(feature = "sqlsrv")] + let mut buffered_bytes = 0usize; + loop { + let fetched = unsafe { SQLFetch(self.stmt) }; + if fetched == SqlReturn::NO_DATA { + break; + } + if !succeeded(fetched) { + self.error = diagnostic(HandleType::Stmt, self.stmt.as_handle(), "SQLFetch"); + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + let mut row = Vec::with_capacity(count as usize); + for index in 1..=count { + let wide = self.columns[index as usize - 1].wide; + let value = self.read_column(index as u16, wide)?; + #[cfg(feature = "sqlsrv")] + let value = if sqlsrv { + let mut value = value; + value = format_sqlsrv_decimal( + value, + &self.columns[index as usize - 1].native_type, + self.sqlsrv_format_decimals, + self.sqlsrv_decimal_places, + ); + if self.sqlsrv_cursor_type == 42 { + buffered_bytes = buffered_bytes + .saturating_add(value.as_ref().map_or(0, Vec::len)); + let limit = usize::try_from(self.sqlsrv_client_buffer_kb) + .unwrap_or(usize::MAX) + .saturating_mul(1024); + if buffered_bytes > limit { + self.error = ErrorState { + sqlstate: "IMSSP".to_string(), + native_code: -59, + message: "Memory limit for buffered query exceeded".to_string(), + }; + connection.error = self.error.clone(); + return Err(self.error.message.clone()); + } + } + value + } else { + value + }; + row.push(value); + } + self.rows.push(row); + } + let mut row_count = -1; + if succeeded(unsafe { SQLRowCount(self.stmt, &mut row_count) }) { + self.row_count = if row_count < 0 { 0 } else { row_count as i64 }; + } else { + self.row_count = 0; + } + connection.changes = self.row_count; + Ok(()) + } + + /// Reads an arbitrary-length current-row value as PDO_ODBC text. + fn read_column(&mut self, column: u16, wide: bool) -> Result>, String> { + let mut value = Vec::new(); + loop { + let mut chunk = [0u8; 8192]; + let mut indicator = 0isize; + let result = unsafe { + SQLGetData( + self.stmt, + column, + if wide { CDataType::WChar } else { CDataType::Char }, + chunk.as_mut_ptr().cast(), + chunk.len() as isize, + &mut indicator, + ) + }; + if indicator == NULL_DATA { + return Ok(None); + } + if result == SqlReturn::NO_DATA { + break; + } + if !succeeded(result) { + self.error = diagnostic(HandleType::Stmt, self.stmt.as_handle(), "SQLGetData"); + return Err(self.error.message.clone()); + } + let capacity = if wide { chunk.len() - 2 } else { chunk.len() - 1 }; + let payload = if result == SqlReturn::SUCCESS_WITH_INFO || indicator == odbc_sys::NO_TOTAL { + capacity + } else { + let reported = usize::try_from(indicator).unwrap_or(0); + if reported >= value.len() { + reported.saturating_sub(value.len()).min(capacity) + } else { + reported.min(capacity) + } + }; + value.extend_from_slice(&chunk[..payload]); + if result == SqlReturn::SUCCESS { + break; + } + } + if wide { + let units = value + .chunks_exact(2) + .map(|bytes| u16::from_ne_bytes([bytes[0], bytes[1]])); + return Ok(Some(String::from_utf16_lossy(&units.collect::>()).into_bytes())); + } + Ok(Some(value)) + } + + /// Advances to the next buffered row. + pub fn step(&mut self) -> i64 { + let next = self.cursor + 1; + if next < self.rows.len() as isize { + self.cursor = next; + 1 + } else { + 0 + } + } + + /// Selects a buffered row using PDO fetch orientation semantics. + pub fn step_oriented(&mut self, orientation: i64, offset: i64) -> i64 { + let target = match orientation { + 0 => self.cursor + 1, + 1 => self.cursor - 1, + 2 => 0, + 3 => self.rows.len() as isize - 1, + 4 => offset as isize, + 5 => self.cursor + offset as isize, + _ => return 0, + }; + if target < 0 || target >= self.rows.len() as isize { + return 0; + } + self.cursor = target; + 1 + } + + /// Advances the native statement to its next result set. + pub fn next_rowset(&mut self, connection: &mut OdbcConn) -> bool { + let result = unsafe { SQLMoreResults(self.stmt) }; + if result == SqlReturn::NO_DATA { + return false; + } + if !succeeded(result) { + self.error = diagnostic(HandleType::Stmt, self.stmt.as_handle(), "SQLMoreResults"); + connection.error = self.error.clone(); + return false; + } + self.materialize_current_result(connection).is_ok() + } + + /// Sets the native ODBC cursor name. + pub fn set_cursor_name(&mut self, name: &str) -> bool { + let result = unsafe { SQLSetCursorName(self.stmt, name.as_ptr(), name.len() as i16) }; + if !succeeded(result) { + self.error = diagnostic(HandleType::Stmt, self.stmt.as_handle(), "SQLSetCursorName"); + return false; + } + true + } + + /// Reads the native ODBC cursor name. + pub fn cursor_name(&mut self) -> String { + let mut buffer = [0u8; 256]; + let mut length = 0i16; + let result = unsafe { + SQLGetCursorName(self.stmt, buffer.as_mut_ptr(), buffer.len() as i16, &mut length) + }; + if !succeeded(result) { + self.error = diagnostic(HandleType::Stmt, self.stmt.as_handle(), "SQLGetCursorName"); + return String::new(); + } + String::from_utf8_lossy(&buffer[..usize::try_from(length).unwrap_or(0).min(buffer.len())]).into_owned() + } + + /// Sets statement-level `ATTR_ASSUME_UTF8`; php-src stores it but returns false. + pub fn set_assume_utf8(&mut self, enabled: bool) -> bool { + self.assume_utf8 = enabled; + false + } + + /// Returns statement-level `ATTR_ASSUME_UTF8`; php-src reports false after filling the value. + pub fn assume_utf8(&self) -> bool { + false + } + + /// Applies a PDO_SQLSRV statement attribute after PDO has created the handle. + #[cfg(feature = "sqlsrv")] + pub fn set_sqlsrv_attribute(&mut self, attribute: i64, value: i64) -> bool { + if !self.flavor.is_sqlsrv() { + return false; + } + let accepted = match attribute { + SQLSRV_ATTR_ENCODING + if matches!( + value, + SQLSRV_ENCODING_DEFAULT + | SQLSRV_ENCODING_BINARY + | SQLSRV_ENCODING_SYSTEM + | SQLSRV_ENCODING_UTF8 + ) => + { + self.sqlsrv_encoding = value; + true + } + SQLSRV_ATTR_QUERY_TIMEOUT if value >= 0 => { + self.sqlsrv_query_timeout = value; + true + } + SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE if value > 0 => { + self.sqlsrv_client_buffer_kb = value; + true + } + SQLSRV_ATTR_FETCHES_NUMERIC_TYPE => { + self.sqlsrv_fetch_numeric = value != 0; + true + } + SQLSRV_ATTR_FETCHES_DATETIME_TYPE => { + self.sqlsrv_fetch_datetime = value != 0; + true + } + SQLSRV_ATTR_FORMAT_DECIMALS => { + self.sqlsrv_format_decimals = value != 0; + true + } + SQLSRV_ATTR_DECIMAL_PLACES => { + self.sqlsrv_decimal_places = if (0..=4).contains(&value) { value } else { -1 }; + true + } + SQLSRV_ATTR_DATA_CLASSIFICATION => { + self.sqlsrv_data_classification = value != 0; + self.sqlsrv_classification = None; + self.sqlsrv_classification_error = None; + true + } + _ => false, + }; + if accepted { + self.error = ErrorState::default(); + } + accepted + } + + /// Reads a PDO_SQLSRV statement attribute from its live statement state. + #[cfg(feature = "sqlsrv")] + pub fn sqlsrv_attribute(&self, attribute: i64) -> Option { + if !self.flavor.is_sqlsrv() { + return None; + } + match attribute { + SQLSRV_ATTR_ENCODING => Some(self.sqlsrv_encoding), + SQLSRV_ATTR_QUERY_TIMEOUT => Some(self.sqlsrv_query_timeout), + SQLSRV_ATTR_DIRECT_QUERY => Some(self.sqlsrv_direct_query as i64), + SQLSRV_ATTR_CURSOR_SCROLL_TYPE => Some(self.sqlsrv_cursor_type), + SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE => Some(self.sqlsrv_client_buffer_kb), + SQLSRV_ATTR_FETCHES_NUMERIC_TYPE => Some(self.sqlsrv_fetch_numeric as i64), + SQLSRV_ATTR_FETCHES_DATETIME_TYPE => Some(self.sqlsrv_fetch_datetime as i64), + SQLSRV_ATTR_FORMAT_DECIMALS => Some(self.sqlsrv_format_decimals as i64), + SQLSRV_ATTR_DECIMAL_PLACES => Some(self.sqlsrv_decimal_places), + SQLSRV_ATTR_DATA_CLASSIFICATION => Some(self.sqlsrv_data_classification as i64), + 10 => Some((self.sqlsrv_cursor_type != 0) as i64), + _ => None, + } + } + + /// Applies SQLSRV prepare-only options before the statement is first executed. + #[cfg(feature = "sqlsrv")] + pub fn configure_sqlsrv_prepare_option(&mut self, attribute: i64, value: i64) -> bool { + if !self.flavor.is_sqlsrv() { + return false; + } + match attribute { + SQLSRV_ATTR_DIRECT_QUERY => { + self.sqlsrv_direct_query = value != 0; + true + } + SQLSRV_ATTR_CURSOR_SCROLL_TYPE if sqlsrv_native_cursor_type(value).is_some() => { + self.sqlsrv_cursor_type == value + } + _ => self.set_sqlsrv_attribute(attribute, value), + } + } + + /// Loads and parses Microsoft ODBC sensitivity metadata on first inspection. + #[cfg(feature = "sqlsrv")] + fn ensure_sqlsrv_classification(&mut self) -> bool { + if !self.flavor.is_sqlsrv() || !self.sqlsrv_data_classification { + return false; + } + if self.sqlsrv_classification.is_some() { + return true; + } + if let Some(error) = self.sqlsrv_classification_error.clone() { + self.error = error; + return false; + } + if !self.executed { + let error = ErrorState { + sqlstate: "IMSSP".to_string(), + native_code: -100, + message: "Data classification metadata is unavailable before execution" + .to_string(), + }; + self.error = error.clone(); + self.sqlsrv_classification_error = Some(error); + return false; + } + let mut descriptor = HDesc::null(); + let descriptor_result = unsafe { + SQLGetStmtAttr( + self.stmt, + StatementAttribute::ImpRowDesc, + (&mut descriptor as *mut HDesc).cast(), + odbc_sys::IS_POINTER, + ptr::null_mut(), + ) + }; + if !succeeded(descriptor_result) { + let error = diagnostic( + HandleType::Stmt, + self.stmt.as_handle(), + "SQLGetStmtAttr SQL_ATTR_IMP_ROW_DESC", + ); + self.error = error.clone(); + self.sqlsrv_classification_error = Some(error); + return false; + } + let mut required = 0i32; + let length_result = unsafe { + SQLGetDescFieldWRaw( + descriptor, + 0, + SQL_CA_SS_DATA_CLASSIFICATION, + ptr::null_mut(), + 0, + &mut required, + ) + }; + if length_result != SqlReturn::SUCCESS || required <= 0 { + let error = diagnostic( + HandleType::Desc, + descriptor.as_handle(), + "SQLGetDescFieldW SQL_CA_SS_DATA_CLASSIFICATION", + ); + self.error = error.clone(); + self.sqlsrv_classification_error = Some(error); + return false; + } + let mut blob = vec![0u8; usize::try_from(required).unwrap_or(0)]; + let mut returned = 0i32; + let data_result = unsafe { + SQLGetDescFieldWRaw( + descriptor, + 0, + SQL_CA_SS_DATA_CLASSIFICATION, + blob.as_mut_ptr().cast(), + required, + &mut returned, + ) + }; + if data_result != SqlReturn::SUCCESS { + let error = diagnostic( + HandleType::Desc, + descriptor.as_handle(), + "SQLGetDescFieldW SQL_CA_SS_DATA_CLASSIFICATION", + ); + self.error = error.clone(); + self.sqlsrv_classification_error = Some(error); + return false; + } + blob.truncate(usize::try_from(returned).unwrap_or(blob.len()).min(blob.len())); + let mut version = 0u32; + let mut version_length = 0i32; + let version_result = unsafe { + SQLGetDescFieldWRaw( + descriptor, + 0, + SQL_CA_SS_DATA_CLASSIFICATION_VERSION, + (&mut version as *mut u32).cast(), + odbc_sys::IS_INTEGER, + &mut version_length, + ) + }; + match parse_sqlsrv_classification_blob( + &blob, + version_result == SqlReturn::SUCCESS && version >= 2, + ) { + Ok(classification) => { + self.sqlsrv_classification = Some(classification); + self.sqlsrv_classification_error = None; + self.error = ErrorState::default(); + true + } + Err(message) => { + let error = ErrorState { + sqlstate: "IMSSP".to_string(), + native_code: -101, + message, + }; + self.error = error.clone(); + self.sqlsrv_classification_error = Some(error); + false + } + } + } + + /// Returns the number of sensitivity pairs for one result column, or `-1` on error. + #[cfg(feature = "sqlsrv")] + pub fn sqlsrv_classification_pair_count(&mut self, column: i64) -> i64 { + if !self.ensure_sqlsrv_classification() { + return -1; + } + usize::try_from(column) + .ok() + .and_then(|column| self.sqlsrv_classification.as_ref()?.columns.get(column)) + .map(|pairs| pairs.len() as i64) + .unwrap_or(-1) + } + + /// Returns one label/information-type string selected by PDO's metadata builder. + #[cfg(feature = "sqlsrv")] + pub fn sqlsrv_classification_text( + &mut self, + column: i64, + pair: i64, + field: i64, + ) -> String { + if !self.ensure_sqlsrv_classification() { + return String::new(); + } + let Some(pair) = usize::try_from(column) + .ok() + .and_then(|column| self.sqlsrv_classification.as_ref()?.columns.get(column)) + .and_then(|pairs| usize::try_from(pair).ok().and_then(|pair| pairs.get(pair))) + else { + return String::new(); + }; + match field { + 0 => pair.label_name.clone(), + 1 => pair.label_id.clone(), + 2 => pair.information_name.clone(), + 3 => pair.information_id.clone(), + _ => String::new(), + } + } + + /// Returns one column sensitivity rank, or `-1` when the server omitted ranks. + #[cfg(feature = "sqlsrv")] + pub fn sqlsrv_classification_pair_rank(&mut self, column: i64, pair: i64) -> i64 { + if !self.ensure_sqlsrv_classification() { + return -1; + } + usize::try_from(column) + .ok() + .and_then(|column| self.sqlsrv_classification.as_ref()?.columns.get(column)) + .and_then(|pairs| usize::try_from(pair).ok().and_then(|pair| pairs.get(pair))) + .and_then(|pair| pair.rank) + .map(i64::from) + .unwrap_or(-1) + } + + /// Returns the result-set sensitivity rank, or `-1` when the server omitted it. + #[cfg(feature = "sqlsrv")] + pub fn sqlsrv_classification_query_rank(&mut self) -> i64 { + if !self.ensure_sqlsrv_classification() { + return -1; + } + self.sqlsrv_classification + .as_ref() + .and_then(|classification| classification.query_rank) + .map(i64::from) + .unwrap_or(-1) + } + + /// Returns the active result column count. + pub fn column_count(&self) -> i64 { + self.columns.len() as i64 + } + + /// Returns one active result column name. + pub fn column_name(&self, index: i64) -> String { + usize::try_from(index).ok().and_then(|index| self.columns.get(index)).map(|column| column.name.clone()).unwrap_or_default() + } + + /// Returns PDO's common text/null storage-class tag, including Informix LOB streams. + pub fn column_type(&self, index: i64) -> i64 { + if self.cell(index).is_none_or(Option::is_none) { + return 5; + } + #[cfg(feature = "sqlsrv")] + if self.flavor.is_sqlsrv() && self.sqlsrv_fetch_numeric { + let data_type = usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .map(|column| column.data_type) + .unwrap_or_default(); + if matches!(data_type, -7 | -6 | 4 | 5) { + return 1; + } + if matches!(data_type, 6 | 7 | 8) { + return 2; + } + } + if usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .is_some_and(|column| column.lob) + { + 4 + } else { + 3 + } + } + + /// Returns the driver-native result-column type name exposed by PDO_INFORMIX. + pub fn column_native_type(&self, index: i64) -> String { + usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .map(|column| column.native_type.clone()) + .unwrap_or_default() + } + + /// Returns the source table name exposed by PDO_INFORMIX when available. + pub fn column_table_name(&self, index: i64) -> String { + usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .map(|column| column.table.clone()) + .unwrap_or_default() + } + + /// Returns the SQL scale captured by `SQLDescribeCol` for PDO_INFORMIX metadata. + pub fn column_scale(&self, index: i64) -> i64 { + usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .map(|column| column.scale) + .unwrap_or_default() + } + + /// Returns PDO core's common maximum column length captured by `SQLDescribeCol`. + pub fn column_len(&self, index: i64) -> i64 { + usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .map(|column| column.len) + .unwrap_or(-1) + } + + /// Returns PDO core's common precision field, which CLI drivers fill from scale. + pub fn column_precision(&self, index: i64) -> i64 { + usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .map(|column| column.precision) + .unwrap_or_default() + } + + /// Returns Informix not-null, unsigned, and auto-increment descriptor bits. + pub fn column_flags(&self, index: i64) -> i64 { + usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .map(|column| column.flags) + .unwrap_or_default() + } + + /// Returns PDO_INFORMIX's metadata parameter type for the described column. + pub fn column_pdo_type(&self, index: i64) -> i64 { + if usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .is_some_and(|column| column.metadata_pdo_lob) + { + 3 + } else { + 2 + } + } + + /// Reports whether SQLSRV should materialize this temporal column as `DateTime`. + #[cfg(feature = "sqlsrv")] + pub fn column_is_datetime(&self, index: i64) -> bool { + self.flavor.is_sqlsrv() + && self.sqlsrv_fetch_datetime + && usize::try_from(index) + .ok() + .and_then(|index| self.columns.get(index)) + .is_some_and(|column| matches!(column.data_type, 91 | 92 | 93 | -154 | -155)) + } + + /// Returns one current value parsed as integer. + pub fn column_int(&self, index: i64) -> i64 { + String::from_utf8_lossy(&self.column_data(index)).parse().unwrap_or(0) + } + + /// Returns one current value parsed as floating point. + pub fn column_double(&self, index: i64) -> f64 { + String::from_utf8_lossy(&self.column_data(index)).parse().unwrap_or(0.0) + } + + /// Returns one current value's exact bytes. + pub fn column_data(&self, index: i64) -> Vec { + self.cell(index).and_then(Option::as_ref).cloned().unwrap_or_default() + } + + /// Returns one current row cell. + fn cell(&self, index: i64) -> Option<&Option>> { + let row = usize::try_from(self.cursor).ok().and_then(|row| self.rows.get(row))?; + usize::try_from(index).ok().and_then(|index| row.get(index)) + } + + /// Returns the statement SQLSTATE. + pub fn sqlstate(&self) -> &str { + &self.error.sqlstate + } + + /// Returns the statement native code. + pub fn errcode(&self) -> i64 { + self.error.native_code + } + + /// Returns the statement diagnostic text. + pub fn errmsg(&self) -> &str { + &self.error.message + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Appends one driver-format length-prefixed UTF-16 classification field. + #[cfg(feature = "sqlsrv")] + fn push_classification_text(blob: &mut Vec, value: &str) { + let utf16 = value.encode_utf16().collect::>(); + blob.push(utf16.len() as u8); + for unit in utf16 { + blob.extend_from_slice(&unit.to_ne_bytes()); + } + } + + /// Parses a named DSN and bridge-only PDO constructor options. + #[test] + #[cfg(feature = "odbc")] + fn parses_named_dsn_options() { + let options = parse_open_options("odbc:inventory;user=user%3Bname;password=p%25w;elephc_odbc_cursor_library=2;elephc_odbc_assume_utf8=1", CliFlavor::Odbc).unwrap(); + assert_eq!(options.source, "inventory"); + assert_eq!(options.username, "user;name"); + assert_eq!(options.password, "p%w"); + assert_eq!(options.cursor_library, SQL_CUR_USE_DRIVER); + assert!(options.assume_utf8); + } + + /// Removes bridge-only options without modifying an ODBC connection string. + #[test] + #[cfg(feature = "odbc")] + fn preserves_direct_connection_string() { + let options = parse_open_options("odbc:Driver={SQLite3};Database=/tmp/test.db;user=me", CliFlavor::Odbc).unwrap(); + assert_eq!(options.source, "Driver={SQLite3};Database=/tmp/test.db"); + assert_eq!(options.username, "me"); + } + + /// Parses PDO_INFORMIX named DSNs and folded constructor credentials. + #[test] + #[cfg(feature = "informix")] + fn parses_informix_named_dsn_options() { + let options = parse_open_options( + "informix:inventory;user=elephc;password=secret", + CliFlavor::Informix, + ) + .unwrap(); + assert_eq!(options.source, "inventory"); + assert_eq!(options.username, "elephc"); + assert_eq!(options.password, "secret"); + } + + /// Parses a PDO_IBM direct DSN and extracts constructor-only CLI attributes. + #[test] + #[cfg(feature = "ibm")] + fn parses_ibm_direct_dsn_options() { + let options = parse_open_options( + "ibm:DATABASE=SAMPLE;HOSTNAME=db2;elephc_ibm_attr_1283=elephc%3Bapp;elephc_ibm_attr_2561=1", + CliFlavor::Ibm, + ) + .unwrap(); + assert_eq!(options.source, "DATABASE=SAMPLE;HOSTNAME=db2"); + assert_eq!( + options.ibm_attributes, + [(PDO_IBM_ATTR_INFO_APPLNAME, "elephc;app".to_string()), (PDO_IBM_ATTR_USE_TRUSTED_CONTEXT, "1".to_string())] + ); + } + + /// Keeps public PDO constant ordering distinct from IBM CLI's account/workstation IDs. + #[test] + #[cfg(feature = "ibm")] + fn maps_ibm_public_attributes_to_native_cli_ids() { + assert_eq!(ibm_native_connection_attribute(PDO_IBM_ATTR_INFO_USERID), Some(1281)); + assert_eq!(ibm_native_connection_attribute(PDO_IBM_ATTR_INFO_ACCTSTR), Some(1284)); + assert_eq!(ibm_native_connection_attribute(PDO_IBM_ATTR_INFO_APPLNAME), Some(1283)); + assert_eq!(ibm_native_connection_attribute(PDO_IBM_ATTR_INFO_WRKSTNNAME), Some(1282)); + } + + /// Applies ODBC brace quoting to semicolons and closing braces. + #[test] + fn quotes_connection_values() { + assert_eq!(quote_connection_value("plain"), "plain"); + assert_eq!(quote_connection_value("a;b}c"), "{a;b}}c}"); + } + + /// Preserves semicolons and escaped braces inside ODBC connection-string values. + #[test] + fn connection_field_split_respects_braced_values() { + assert_eq!( + split_connection_fields("Driver={A;B};PWD={x}};y};UID=user"), + ["Driver={A;B}", "PWD={x}};y}", "UID=user"] + ); + } + + /// Bounds oversized input/output values to the caller's declared max length. + #[test] + fn output_buffer_respects_declared_max_length() { + let mut payload = vec![b'A'; 64]; + let input_length = prepare_output_buffer( + &mut payload, + OutputSpec { + max_length: 4, + input_output: true, + lob: false, + }, + 4000, + ); + assert_eq!(input_length, 4); + assert_eq!(payload, b"AAAA"); + } + + /// Derives SQLSRV value types without trusting temporary-table text descriptors. + #[test] + #[cfg(feature = "sqlsrv")] + fn maps_sqlsrv_values_to_native_odbc_types() { + assert_eq!( + sqlsrv_double_parameter_types( + CliFlavor::Sqlsrv, + &OdbcBind::Double(12.5), + SqlDataType::EXT_LONG_VARCHAR, + ), + Some((CDataType::Double, SqlDataType::FLOAT)) + ); + assert_eq!( + sqlsrv_double_parameter_types( + CliFlavor::Sqlsrv, + &OdbcBind::Double(12.5), + SqlDataType::DECIMAL, + ), + Some((CDataType::Double, SqlDataType::DECIMAL)) + ); + assert_eq!( + sqlsrv_double_parameter_types( + CliFlavor::Sqlsrv, + &OdbcBind::Text(b"12.5".to_vec()), + SqlDataType::EXT_LONG_VARCHAR, + ), + None + ); + assert_eq!( + sqlsrv_parameter_defaults( + &OdbcBind::Text(b"2026-07-17 12:34:56".to_vec()), + SQLSRV_ENCODING_UTF8, + ), + (SqlDataType::EXT_W_VARCHAR, 4000, 0) + ); + assert_eq!( + sqlsrv_parameter_defaults( + &OdbcBind::Text(b"2026-07-17 12:34:56".to_vec()), + SQLSRV_ENCODING_SYSTEM, + ), + (SqlDataType::VARCHAR, 8000, 0) + ); + assert_eq!( + sqlsrv_parameter_defaults(&OdbcBind::Binary(vec![0, 255]), SQLSRV_ENCODING_BINARY), + (SqlDataType::EXT_VAR_BINARY, 8000, 0) + ); + assert_eq!(sqlsrv_native_cursor_type(3), Some(3)); + assert_eq!(sqlsrv_native_cursor_type(42), Some(0)); + assert_eq!(sqlsrv_native_cursor_type(99), None); + } + + /// Recognizes both short and header-style Informix UDT names as metadata LOBs. + #[test] + fn recognizes_informix_metadata_lob_names() { + assert!(informix_metadata_is_lob("BLOB")); + assert!(informix_metadata_is_lob("sql_infx_udt_clob")); + assert!(!informix_metadata_is_lob("LONG VARCHAR")); + } + + /// Preserves PDO_IBM's BOOLEAN/BIT fallthrough and binary/LOB metadata mapping. + #[test] + #[cfg(feature = "ibm")] + fn recognizes_ibm_metadata_lob_types() { + assert!(ibm_metadata_is_lob(16)); + assert!(ibm_metadata_is_lob(-7)); + assert!(ibm_metadata_is_lob(-98)); + assert!(ibm_metadata_is_lob(-370)); + assert!(!ibm_metadata_is_lob(4)); + assert!(!ibm_metadata_is_lob(12)); + } + + /// Parses SQLSRV's direct DSN while separating folded PDO credentials. + #[test] + #[cfg(feature = "sqlsrv")] + fn parses_sqlsrv_dsn_options() { + let options = parse_open_options( + "sqlsrv:Server=localhost,1433;Database=app;Encrypt=yes;user=sa;password=p%25w", + CliFlavor::Sqlsrv, + ) + .unwrap(); + assert_eq!( + options.source, + "Server=localhost,1433;Database=app;Encrypt=yes" + ); + assert_eq!(options.username, "sa"); + assert_eq!(options.password, "p%w"); + } + + /// Extracts SQLSRV's access token instead of leaking it into the connection string. + #[test] + #[cfg(feature = "sqlsrv")] + fn parses_sqlsrv_access_token() { + let options = parse_open_options( + "sqlsrv:Server=tcp:example.database.windows.net;AccessToken=abc.def;ConnectionPooling=yes", + CliFlavor::Sqlsrv, + ) + .unwrap(); + assert_eq!(options.source, "Server=tcp:example.database.windows.net"); + assert_eq!(options.sqlsrv_access_token.as_deref(), Some(b"abc.def".as_slice())); + assert!(!options.username_supplied); + assert!(!options.password_supplied); + } + + /// Reads SQLSRV pooling only from the driver manager's `[ODBC]` section. + #[test] + #[cfg(feature = "sqlsrv")] + fn parses_sqlsrv_driver_manager_pooling() { + assert_eq!( + sqlsrv_pooling_from_ini("[Other]\nPooling=No\n[ODBC]\nPooling = Yes\n"), + Some(true) + ); + assert_eq!(sqlsrv_pooling_from_ini("[ODBC]\nPooling=off\n"), Some(false)); + assert_eq!(sqlsrv_pooling_from_ini("[ODBC]\nTrace=No\n"), None); + } + + /// Encodes Microsoft's access-token structure with a byte count and UCS-2 padding. + #[test] + #[cfg(feature = "sqlsrv")] + fn builds_sqlsrv_access_token_buffer() { + let buffer = sqlsrv_access_token_buffer(b"abc"); + let bytes = unsafe { + std::slice::from_raw_parts(buffer.as_ptr().cast::(), buffer.len() * 4) + }; + assert_eq!(u32::from_ne_bytes(bytes[..4].try_into().unwrap()), 6); + assert_eq!(&bytes[4..10], &[b'a', 0, b'b', 0, b'c', 0]); + assert_eq!(sqlsrv_token_fingerprint(b"abc"), 0xe71f_a219_0541_574b); + } + + /// Parses labels, information types, column ranks, and query rank from ODBC metadata. + #[test] + #[cfg(feature = "sqlsrv")] + fn parses_sqlsrv_classification_metadata() { + let mut blob = Vec::new(); + blob.extend_from_slice(&1u16.to_ne_bytes()); + push_classification_text(&mut blob, "Secret"); + push_classification_text(&mut blob, "L1"); + blob.extend_from_slice(&1u16.to_ne_bytes()); + push_classification_text(&mut blob, "PII"); + push_classification_text(&mut blob, "I1"); + blob.extend_from_slice(&2i32.to_ne_bytes()); + blob.extend_from_slice(&1u16.to_ne_bytes()); + blob.extend_from_slice(&1u16.to_ne_bytes()); + blob.extend_from_slice(&0u16.to_ne_bytes()); + blob.extend_from_slice(&0u16.to_ne_bytes()); + blob.extend_from_slice(&1i32.to_ne_bytes()); + + let parsed = parse_sqlsrv_classification_blob(&blob, true).unwrap(); + assert_eq!(parsed.query_rank, Some(2)); + assert_eq!(parsed.columns.len(), 1); + assert_eq!(parsed.columns[0].len(), 1); + assert_eq!(parsed.columns[0][0].label_name, "Secret"); + assert_eq!(parsed.columns[0][0].label_id, "L1"); + assert_eq!(parsed.columns[0][0].information_name, "PII"); + assert_eq!(parsed.columns[0][0].information_id, "I1"); + assert_eq!(parsed.columns[0][0].rank, Some(1)); + } + + /// Quotes SQLSRV emulated values without replacing markers inside literals. + #[test] + #[cfg(feature = "sqlsrv")] + fn interpolates_sqlsrv_emulated_statement() { + let rendered = interpolate_sqlsrv( + "SELECT '?' AS marker, ? AS text, ? AS payload", + &[1, 2], + &[OdbcBind::Text(b"O'Brien".to_vec()), OdbcBind::Binary(vec![0, 255])], + true, + ) + .unwrap(); + assert_eq!( + rendered, + "SELECT '?' AS marker, N'O''Brien' AS text, 0x00FF AS payload" + ); + } + + /// Executes binds, typed text fetches, transactions, and multiple results against a live DSN. + #[test] + #[ignore] + #[cfg(feature = "odbc")] + fn live_odbc_round_trip() { + let dsn = std::env::var("ELEPHC_ODBC_DSN") + .expect("ELEPHC_ODBC_DSN is required for the ignored ODBC live test"); + let mut connection = OdbcConn::open_odbc(&dsn).expect("open live ODBC connection"); + assert!(connection.exec("CREATE TEMP TABLE elephc_odbc_bridge_test (id INTEGER, name VARCHAR(40))") >= 0); + + let mut insert = OdbcStmt::new( + &mut connection, + 1, + "INSERT INTO elephc_odbc_bridge_test (id, name) VALUES (:id, :name)", + 0, + ) + .expect("prepare ODBC insert"); + assert!(insert.bind_int(insert.parameter_index("id"), 7)); + assert!(insert.bind_text(insert.parameter_index("name"), "Éléphant".as_bytes().to_vec())); + insert.execute(&mut connection).expect("execute ODBC insert"); + assert_eq!(connection.changes, 1); + + let mut select = OdbcStmt::new( + &mut connection, + 1, + "SELECT id, name FROM elephc_odbc_bridge_test ORDER BY id", + 0, + ) + .expect("prepare ODBC select"); + select.execute(&mut connection).expect("execute ODBC select"); + assert_eq!(select.step(), 1); + assert_eq!(select.column_data(0), b"7"); + assert_eq!(select.column_data(1), "Éléphant".as_bytes()); + + assert!(connection.begin()); + assert_eq!( + connection.exec("INSERT INTO elephc_odbc_bridge_test (id, name) VALUES (8, 'rollback')"), + 1 + ); + assert!(connection.rollback()); + + let mut count = OdbcStmt::new( + &mut connection, + 1, + "SELECT COUNT(*) FROM elephc_odbc_bridge_test", + 0, + ) + .expect("prepare ODBC count"); + count.execute(&mut connection).expect("execute ODBC count"); + assert_eq!(count.step(), 1); + assert_eq!(count.column_data(0), b"1"); + + let mut rowsets = OdbcStmt::new(&mut connection, 1, "SELECT 1; SELECT 2", 0) + .expect("prepare ODBC rowsets"); + rowsets.execute(&mut connection).expect("execute first ODBC rowset"); + assert_eq!(rowsets.step(), 1); + assert_eq!(rowsets.column_data(0), b"1"); + assert!(rowsets.next_rowset(&mut connection)); + assert_eq!(rowsets.step(), 1); + assert_eq!(rowsets.column_data(0), b"2"); + } + + /// Exercises SQLPrepareW, Unicode binds/fetches, numeric typing, and identity lookup live. + #[test] + #[ignore] + #[cfg(feature = "sqlsrv")] + fn live_sqlsrv_round_trip() { + let dsn = std::env::var("ELEPHC_SQLSRV_DSN") + .expect("ELEPHC_SQLSRV_DSN is required for the ignored SQLSRV live test"); + let mut connection = OdbcConn::open_sqlsrv(&dsn).expect("open live SQLSRV connection"); + assert!(connection.exec( + "CREATE TABLE #elephc_sqlsrv_bridge (id INT IDENTITY(1,1), amount DECIMAL(10,2), happened DATETIME2, label NVARCHAR(40))" + ) >= 0); + + let mut insert = OdbcStmt::new( + &mut connection, + 1, + "INSERT INTO #elephc_sqlsrv_bridge(amount, happened, label) VALUES (:amount, :happened, :label)", + 0, + ) + .expect("prepare SQLSRV insert"); + assert!(insert.bind_double(insert.parameter_index("amount"), 12.5)); + assert!(insert.bind_text( + insert.parameter_index("happened"), + b"2026-07-17 12:34:56".to_vec(), + )); + assert!(insert.bind_text( + insert.parameter_index("label"), + "Éléphant".as_bytes().to_vec(), + )); + insert.execute(&mut connection).expect("execute SQLSRV insert"); + assert_eq!(connection.last_insert_id(None), "1"); + + let mut select = OdbcStmt::new( + &mut connection, + 1, + "SELECT id, amount, happened, label AS [libellé] FROM #elephc_sqlsrv_bridge", + 2 | (42 << 8), + ) + .expect("prepare SQLSRV select"); + assert!(select.configure_sqlsrv_prepare_option( + SQLSRV_ATTR_CURSOR_SCROLL_TYPE, + 42, + )); + assert!(select.set_sqlsrv_attribute(SQLSRV_ATTR_FETCHES_NUMERIC_TYPE, 1)); + assert!(select.set_sqlsrv_attribute(SQLSRV_ATTR_FETCHES_DATETIME_TYPE, 1)); + select.execute(&mut connection).expect("execute SQLSRV select"); + assert_eq!(select.sqlsrv_attribute(SQLSRV_ATTR_CURSOR_SCROLL_TYPE), Some(42)); + assert_eq!(select.column_name(3), "libellé"); + assert_eq!(select.step(), 1); + assert_eq!(select.column_type(0), 1); + assert_eq!(select.column_data(0), b"1"); + assert_eq!(select.column_data(1), b"12.50"); + assert!(select.column_is_datetime(2)); + assert!(String::from_utf8_lossy(&select.column_data(2)).starts_with("2026-07-17 12:34:56")); + assert_eq!(select.column_data(3), "Éléphant".as_bytes()); + } +} diff --git a/crates/elephc-pdo/src/pg.rs b/crates/elephc-pdo/src/pg.rs index e480c2c6e0..e34b5885c4 100644 --- a/crates/elephc-pdo/src/pg.rs +++ b/crates/elephc-pdo/src/pg.rs @@ -1,6 +1,7 @@ //! Purpose: //! The PostgreSQL driver for the elephc PDO bridge. Connects with the pure-Rust -//! synchronous `postgres` client (no system libpq), so compiled PHP binaries +//! `tokio-postgres` client behind a synchronous bridge boundary (no system libpq), +//! so compiled PHP binaries //! stay standalone and talk to a running PostgreSQL server over the network. //! //! Called from: @@ -9,21 +10,35 @@ //! //! Key details: //! - PDO `?` / `:name` placeholders are translated to PostgreSQL's `$1, $2, …` -//! at prepare time (respecting `'…'` string literals and the `::type` cast -//! operator); the named map lets `bind_parameter_index(":name")` resolve. +//! at prepare time by a scanner that skips `--`/`/* */` comments, `'…'` +//! (incl. `E'…'` backslash-escape strings) and `"…"` literals, `$tag$…$tag$` +//! dollar-quoted strings, the `::type` cast operator, and the `??` jsonb +//! operator escape, so a `?`/`:name` inside any of those is never mistaken +//! for a real placeholder; the named map lets `bind_parameter_index(":name")` +//! resolve. A SQL text mixing `?` and `:name` is rejected at `prepare()` with +//! `HY093` (PDO forbids the combination). //! - A statement is prepared server-side for column metadata, then executed -//! lazily on the first `step()`. The whole result set is materialized into -//! typed `Cell` values, so the column accessors read from owned data and -//! per-value NULL is reported through the SQLite-compatible type codes -//! (1=int, 2=float, 3=text, 4=bytea/blob, 5=null). +//! lazily on the first `step()`. Buffered statements retain typed `Cell` rows; +//! native prefetch-off statements move the client into a demand worker and +//! retain only the current row. //! - Parameter values are encoded according to the prepared statement's inferred //! parameter types, so an int bound where the column is `int4` is sent as a //! 4-byte int, a text where the column is `int` is parsed, etc. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::fs; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread::JoinHandle; -use postgres::types::{to_sql_checked, IsNull, ToSql, Type}; -use postgres::{Client, NoTls, Row, Statement}; +use futures_util::{future::poll_fn, pin_mut, SinkExt, TryStreamExt}; +use tokio::runtime::{Builder as RuntimeBuilder, Runtime}; +use tokio_postgres::types::{to_sql_checked, IsNull, Kind, ToSql, Type}; +use tokio_postgres::{ + AsyncMessage, Client as AsyncClient, Config, Connection, Error as PgError, NoTls, Row, + SimpleQueryMessage, Statement, +}; /// One materialized column value, already decoded to a PHP-friendly scalar. pub enum Cell { @@ -42,33 +57,253 @@ pub enum Bind { Int(i64), Float(f64), Text(String), + /// Raw bytes, bound directly (bypassing the text re-encoding `Param::to_sql` + /// otherwise does) so a BLOB-style parameter round-trips embedded NUL bytes + /// and arbitrary binary content unchanged. + Bytes(Vec), } /// A live PostgreSQL connection plus the last operation's bookkeeping that PDO /// reads back (`rowCount`, `lastInsertId`, `errorCode`/`errorInfo`). pub struct PgConn { - pub client: Client, + client: PgClientSlot, pub changes: i64, pub errmsg: String, + /// Native (driver-specific) error code for the connection's last operation, read + /// back as `errorInfo()[1]`: `0` on success, [`PG_NATIVE_ERRCODE`] on failure. + /// PostgreSQL has no integer error code — see that constant for the full rationale. pub errcode: i64, + /// 5-char SQLSTATE for the connection's last operation, taken from the + /// server's `ErrorResponse` (`tokio_postgres::error::Error::code`), which + /// already parses the wire protocol's `SQLSTATE` field ('C' code). "00000" on + /// success; "HY000" for an error that carries no SQLSTATE (a transport/ + /// connection failure rather than a server-reported error). + pub sqlstate: String, + /// Buffer of server NOTICE message texts captured during query execution, + /// backing `Pdo\Pgsql::setNoticeCallback()`. The tokio-postgres connection task + /// copies every `AsyncMessage::Notice` here, and the prelude drains this buffer + /// after each `exec()`/`query()` and dispatches each message to the PHP callback. + /// Shared (`Arc`) because the callback fires from the client's connection + /// driver, which may run on a separate thread from the query call. + pub notices: Arc>>, + /// Default `PDO::ATTR_PREFETCH` state snapshotted by prepared statements. + pub prefetch: bool, + /// Monotonic query generation used to invalidate an older unbuffered cursor + /// when PostgreSQL starts another query on the same connection. + query_generation: u64, + /// Demand-driven native row stream currently borrowing this connection. + active_stream: Option, + /// Monotonic identity used to distinguish an invalidated older statement. + next_stream_id: u64, + /// Transaction state updated from every successful bridge-owned command. + pub in_transaction: bool, } -/// A live PostgreSQL prepared statement and its lazily-materialized result. +/// A live PostgreSQL prepared statement and its buffered or demand-driven result. pub struct PgStmt { pub conn_id: i64, - pub statement: Statement, + /// Original SQL retained for statement-level diagnostics. + pub query_string: String, + pub statement: Option, + /// SQL with PDO placeholders translated to PostgreSQL `$N` markers. + emulated_sql: Option, + /// Generated marker byte ranges and their 1-based bind indexes. + emulated_markers: Vec<(usize, usize, usize)>, + /// Most recent client-rendered SQL, exposed by `debugDumpParams()`. + pub sent_sql: String, /// Maps a bare named placeholder (`name` from `:name`) to its 1-based index. pub named_map: HashMap, /// Bound parameter values, indexed by 0-based position (`$1` → index 0). pub binds: Vec, + /// Whether each slot was explicitly supplied for the current execution. + bound: Vec, /// Result column names, available from the prepare (before execution). pub col_names: Vec, - /// Materialized rows; each row is a vector of decoded column cells. + /// Source table names resolved from each column's PostgreSQL table OID. + col_tables: Vec, + /// Buffered rows, or the single active row for a native unbuffered stream. pub rows: Vec>, /// Current 0-based row index; `-1` before the first `step()`. pub cursor: isize, - /// Whether the query has been executed (results materialized) yet. + /// Whether the query has been executed yet. pub executed: bool, + /// Whether this statement buffers its full result (`ATTR_PREFETCH != 0`). + pub buffered: bool, + /// Query generation assigned when an unbuffered execution starts. + query_generation: u64, + /// Connection-owned demand stream used when `ATTR_PREFETCH` disables buffering. + stream_id: Option, + /// Whether the selected PHP compatibility version supports lazy simple queries. + simple_streaming: bool, +} + +impl Drop for PgConn { + /// Stops any active row worker before the owning PDO connection is released. + fn drop(&mut self) { + self.finish_active_stream(); + } +} + +/// Keeps the synchronous client optional while a streaming worker temporarily owns it. +struct PgClientSlot(Option); + +impl Deref for PgClientSlot { + type Target = Client; + + /// Borrows the connected client while no row worker owns it. + fn deref(&self) -> &Self::Target { + self.0 + .as_ref() + .expect("PostgreSQL client is owned by an active row stream") + } +} + +impl DerefMut for PgClientSlot { + /// Mutably borrows the connected client while no row worker owns it. + fn deref_mut(&mut self) -> &mut Self::Target { + self.0 + .as_mut() + .expect("PostgreSQL client is owned by an active row stream") + } +} + +/// One asynchronous notification copied out of the connection driver task. +struct PgNotification { + channel: String, + process_id: i32, + payload: String, +} + +/// Owns the asynchronous PostgreSQL client, its runtime, and notification queue +/// while presenting synchronous methods to the C ABI bridge. +struct Client { + runtime: Runtime, + inner: AsyncClient, + notifications: mpsc::Receiver, +} + +impl Client { + /// Blocks on a server-side prepare operation. + fn prepare(&mut self, query: &str) -> Result { + self.runtime.block_on(self.inner.prepare(query)) + } + + /// Blocks on a typed query and returns all rows. + fn query( + &mut self, + query: &T, + params: &[&(dyn ToSql + Sync)], + ) -> Result, PgError> + where + T: ?Sized + tokio_postgres::ToStatement, + { + self.runtime.block_on(self.inner.query(query, params)) + } + + /// Blocks on a command and returns its affected-row count. + fn execute( + &mut self, + query: &T, + params: &[&(dyn ToSql + Sync)], + ) -> Result + where + T: ?Sized + tokio_postgres::ToStatement, + { + self.runtime.block_on(self.inner.execute(query, params)) + } + + /// Blocks on a query expected to return exactly one row. + fn query_one( + &mut self, + query: &T, + params: &[&(dyn ToSql + Sync)], + ) -> Result + where + T: ?Sized + tokio_postgres::ToStatement, + { + self.runtime.block_on(self.inner.query_one(query, params)) + } + + /// Blocks on a query expected to return zero or one row. + fn query_opt( + &mut self, + query: &T, + params: &[&(dyn ToSql + Sync)], + ) -> Result, PgError> + where + T: ?Sized + tokio_postgres::ToStatement, + { + self.runtime.block_on(self.inner.query_opt(query, params)) + } + + /// Blocks on a multi-command simple-protocol query and collects its messages. + fn simple_query(&mut self, query: &str) -> Result, PgError> { + self.runtime.block_on(self.inner.simple_query(query)) + } + + /// Executes a command batch without returning rows. + fn batch_execute(&mut self, query: &str) -> Result<(), PgError> { + self.runtime.block_on(self.inner.batch_execute(query)) + } + + /// Sends all input bytes through PostgreSQL COPY FROM STDIN. + fn copy_in_bytes(&mut self, query: &str, data: &[u8]) -> Result { + self.runtime.block_on(async { + let sink = self.inner.copy_in(query).await?; + pin_mut!(sink); + sink.send(bytes::Bytes::copy_from_slice(data)).await?; + sink.finish().await + }) + } + + /// Collects every PostgreSQL COPY TO STDOUT chunk. + fn copy_out_bytes(&mut self, query: &str) -> Result, PgError> { + self.runtime.block_on(async { + let stream = self.inner.copy_out(query).await?; + pin_mut!(stream); + let mut output = Vec::new(); + while let Some(chunk) = stream.try_next().await? { + output.extend_from_slice(&chunk); + } + Ok(output) + }) + } + + /// Reports whether the protocol client has observed terminal connection closure. + fn is_closed(&self) -> bool { + self.inner.is_closed() + } + + /// Receives one already-buffered notification or waits up to `timeout`. + fn notification(&self, timeout: std::time::Duration) -> Option { + if timeout.is_zero() { + self.notifications.try_recv().ok() + } else { + self.notifications.recv_timeout(timeout).ok() + } + } +} + +/// Commands sent to the worker so it reads at most one wire row per PDO fetch. +enum PgStreamCommand { + Next, + Close, +} + +/// Results returned by a PostgreSQL row-stream worker. +enum PgStreamResponse { + Started, + Row(Vec, Vec), + Finished(Client, i64), + Failed(Client, String, String), +} + +/// Connection-owned control plane for one active unbuffered statement. +struct PgActiveStream { + id: u64, + commands: mpsc::Sender, + responses: mpsc::Receiver, + worker: Option>, } /// Encodes a pending `Bind` according to the inferred PostgreSQL parameter type, @@ -90,11 +325,19 @@ impl ToSql for Param { fn to_sql( &self, ty: &Type, - out: &mut postgres::types::private::BytesMut, + out: &mut tokio_postgres::types::private::BytesMut, ) -> Result> { if let Bind::Null = self.bind { return Ok(IsNull::Yes); } + if let Bind::Bytes(b) = &self.bind { + // Raw bytes bind directly regardless of the inferred parameter type + // (calling `to_sql` rather than `to_sql_checked` skips the `accepts` + // gate), so a BLOB parameter's embedded NUL / non-UTF-8 bytes reach + // the server unchanged instead of going through the text re-encoding + // below. + return b.to_sql(ty, out); + } // PDO/PHP sends parameters as text and lets the server coerce them to the // column type. We replicate that: take the bound value's canonical string // form and re-encode it for the parameter type the prepared statement @@ -104,6 +347,7 @@ impl ToSql for Param { Bind::Int(v) => v.to_string(), Bind::Float(v) => v.to_string(), Bind::Text(t) => t.clone(), + Bind::Bytes(_) => unreachable!("handled above"), Bind::Null => unreachable!(), }; let st = s.trim(); @@ -165,90 +409,1231 @@ fn parse_datetime_utc( /// Parses a PDO `pgsql:` DSN (semicolon-separated `key=value` pairs) into a /// libpq-style connection string the `postgres` client accepts. Recognises the /// PDO key `dbname` as-is and passes other keys (`host`, `port`, `user`, -/// `password`, `sslmode`, …) straight through. Returns an error for a DSN -/// without the `pgsql:` prefix. +/// `password`, …) straight through — including `connect_timeout` (P2-1: the +/// prelude folds this in from `PDO::ATTR_TIMEOUT` alongside the credentials, and +/// libpq's own conninfo parser already understands the key, so no bridge-side +/// special-casing is needed here). The TLS keys (`sslmode`, `sslrootcert`, +/// `sslcert`, `sslkey`) are deliberately NOT forwarded: tokio-postgres's +/// connection-string parser only accepts `sslmode=disable|prefer|require` (it +/// rejects libpq's `verify-ca`/`verify-full`) and rejects the file-path keys +/// outright, so [`parse_tls`] extracts them and `open` applies them to the +/// `Config`/rustls connector instead. Returns an error for a DSN without the +/// `pgsql:` prefix. +/// +/// [`resolve_dsn_options`] first expands libpq service/passfile/environment +/// sources and compatibility aliases. This function then forwards only keys +/// `postgres::Config` recognizes, while client encoding and rustls-specific TLS +/// controls are consumed separately. GSS, CRL, replication/authentication modes, +/// and typos remain explicit errors rather than silently ignored options. +/// +/// F-PG-03 / F-CORE-10: when neither the DSN body nor the caller's +/// `PDO::ATTR_TIMEOUT` (which the prelude folds into the DSN as +/// `;connect_timeout=`, so both arrive here as the same key) supplies a +/// `connect_timeout`, one of 30 s is appended. php-src's pgsql handle factory +/// does the same (`pgsql_driver.c:1350,1373,1381` default `connect_timeout = 30` +/// and always append it to the conninfo), so every real-PHP pg connection is +/// bounded; without it the pure-Rust `postgres` client has no application-level +/// connect timeout and hangs for minutes on a black-holed host. php-src's *quirk* +/// of overwriting a DSN-supplied `connect_timeout=` with its own value is +/// deliberately NOT imitated: a value the DSN spells out wins, and the default +/// only fills the gap when nothing else did. +#[cfg(test)] pub fn parse_dsn(dsn: &str) -> Result { + let options = resolve_dsn_options(dsn)?; + parse_resolved_dsn(&options) +} + +/// Resolves libpq-compatible service, environment, and password-file sources. +/// +/// Precedence matches libpq: explicit PDO DSN values win over a selected service, +/// service values win over `PG*` environment defaults, and a password file is +/// consulted only when no password was supplied by a higher-priority source. +pub(crate) fn resolve_dsn_options(dsn: &str) -> Result, String> { + let explicit = explicit_dsn_options(dsn)?; + + let service_name = explicit + .get("service") + .cloned() + .or_else(|| std::env::var("PGSERVICE").ok().filter(|value| !value.is_empty())); + let service_file = explicit + .get("servicefile") + .map(PathBuf::from) + .or_else(|| std::env::var_os("PGSERVICEFILE").map(PathBuf::from)) + .or_else(default_service_file); + + let mut options = if let Some(service_name) = service_name { + let path = service_file.ok_or_else(|| { + format!("PostgreSQL service '{service_name}' requested but no service file is available") + })?; + load_service(&path, &service_name)? + } else { + BTreeMap::new() + }; + + for (key, environment) in pg_environment_keys() { + if !options.contains_key(*key) { + if let Ok(value) = std::env::var(environment) { + if !value.is_empty() { + options.insert((*key).to_string(), value); + } + } + } + } + if !options.contains_key("user") && !explicit.contains_key("user") { + if let Some(user) = std::env::var("USER") + .ok() + .or_else(|| std::env::var("LOGNAME").ok()) + .or_else(|| whoami::username().ok()) + .filter(|value| !value.is_empty()) + { + options.insert("user".to_string(), user); + } + } + options.extend(explicit); + options.remove("service"); + options.remove("servicefile"); + + if !options.contains_key("application_name") { + if let Some(value) = options.remove("fallback_application_name") { + options.insert("application_name".to_string(), value); + } + } else { + options.remove("fallback_application_name"); + } + if !options.contains_key("sslmode") { + if matches!(options.get("requiressl").map(String::as_str), Some("1")) { + options.insert("sslmode".to_string(), "require".to_string()); + } + } + if let Some(value) = options.get("requiressl") { + if !matches!(value.as_str(), "0" | "1") { + return Err(format!("invalid PostgreSQL requiressl value '{value}': expected 0 or 1")); + } + } + options.remove("requiressl"); + if let Some(value) = options.get("sslcompression") { + if !matches!(value.as_str(), "0" | "1") { + return Err(format!( + "invalid PostgreSQL sslcompression value '{value}': expected 0 or 1" + )); + } + } + options.remove("sslcompression"); + apply_default_tls_files(&mut options); + + if !options.contains_key("password") { + if let Some(path) = options + .get("passfile") + .map(PathBuf::from) + .or_else(|| std::env::var_os("PGPASSFILE").map(PathBuf::from)) + .or_else(default_password_file) + { + if let Some(password) = password_from_file(&path, &options)? { + options.insert("password".to_string(), password); + } + } + } + options.remove("passfile"); + Ok(options) +} + +/// Parses only the explicit PDO DSN pairs without resolving libpq configuration +/// sources, allowing the libpq backend to delegate those sources to `PQconnectdb`. +pub(crate) fn explicit_dsn_options(dsn: &str) -> Result, String> { let body = dsn .strip_prefix("pgsql:") .ok_or_else(|| "could not find driver (expected a pgsql: DSN)".to_string())?; - let mut parts: Vec = Vec::new(); + let mut explicit = parse_option_pairs(body, "PostgreSQL DSN")?; + explicit.retain(|key, _| !key.starts_with("elephc_odbc_")); + for key in ["user", "password"] { + if let Some(value) = explicit.get_mut(key) { + *value = percent_decode_credential(value); + } + } + Ok(explicit) +} + +/// Applies libpq's conventional per-user certificate, key, root, and CRL paths +/// when the corresponding option was not supplied explicitly. +fn apply_default_tls_files(options: &mut BTreeMap) { + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { + return; + }; + let directory = home.join(".postgresql"); + for (key, filename) in [ + ("sslrootcert", "root.crt"), + ("sslcert", "postgresql.crt"), + ("sslkey", "postgresql.key"), + ("sslcrl", "root.crl"), + ] { + if options.contains_key(key) { + continue; + } + let path = directory.join(filename); + if path.is_file() { + options.insert(key.to_string(), path.display().to_string()); + } + } +} + +/// Parses semicolon-separated `key=value` options with last-value-wins semantics. +fn parse_option_pairs(body: &str, source: &str) -> Result, String> { + let mut options = BTreeMap::new(); for pair in body.split(';') { let pair = pair.trim(); if pair.is_empty() { continue; } let Some((key, value)) = pair.split_once('=') else { - continue; + return Err(format!("invalid {source} option '{pair}': expected key=value")); }; let key = key.trim(); - let value = value.trim(); + if key.is_empty() { + return Err(format!("invalid {source} option '{pair}': empty key")); + } + options.insert(key.to_ascii_lowercase(), value.trim().to_string()); + } + Ok(options) +} + +/// Returns the libpq environment variable corresponding to each connection key. +fn pg_environment_keys() -> &'static [(&'static str, &'static str)] { + &[ + ("host", "PGHOST"), + ("hostaddr", "PGHOSTADDR"), + ("port", "PGPORT"), + ("dbname", "PGDATABASE"), + ("user", "PGUSER"), + ("password", "PGPASSWORD"), + ("application_name", "PGAPPNAME"), + ("connect_timeout", "PGCONNECT_TIMEOUT"), + ("client_encoding", "PGCLIENTENCODING"), + ("options", "PGOPTIONS"), + ("sslmode", "PGSSLMODE"), + ("requiressl", "PGREQUIRESSL"), + ("sslcompression", "PGSSLCOMPRESSION"), + ("sslrootcert", "PGSSLROOTCERT"), + ("sslcert", "PGSSLCERT"), + ("sslkey", "PGSSLKEY"), + ("sslcertmode", "PGSSLCERTMODE"), + ("sslpassword", "PGSSLPASSWORD"), + ("sslcrl", "PGSSLCRL"), + ("sslcrldir", "PGSSLCRLDIR"), + ("sslsni", "PGSSLSNI"), + ("ssl_min_protocol_version", "PGSSLMINPROTOCOLVERSION"), + ("ssl_max_protocol_version", "PGSSLMAXPROTOCOLVERSION"), + ("sslnegotiation", "PGSSLNEGOTIATION"), + ("gssencmode", "PGGSSENCMODE"), + ("require_auth", "PGREQUIREAUTH"), + ("passfile", "PGPASSFILE"), + ("target_session_attrs", "PGTARGETSESSIONATTRS"), + ("channel_binding", "PGCHANNELBINDING"), + ("load_balance_hosts", "PGLOADBALANCEHOSTS"), + ("tcp_user_timeout", "PGTCPUSER_TIMEOUT"), + ] +} + +/// Returns libpq's per-user service-file location for supported Unix targets. +fn default_service_file() -> Option { + let user_file = std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".pg_service.conf")) + .filter(|path| path.is_file()); + user_file.or_else(|| { + std::env::var_os("PGSYSCONFDIR") + .map(PathBuf::from) + .map(|directory| directory.join("pg_service.conf")) + .filter(|path| path.is_file()) + }) +} + +/// Loads one section from a libpq `pg_service.conf` file. +fn load_service(path: &Path, service_name: &str) -> Result, String> { + let contents = fs::read_to_string(path) + .map_err(|error| format!("PostgreSQL service file '{}': {error}", path.display()))?; + let mut selected = false; + let mut found = false; + let mut options = BTreeMap::new(); + for (line_number, raw) in contents.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + selected = line[1..line.len() - 1].trim() == service_name; + found |= selected; + continue; + } + if !selected { + continue; + } + let Some((key, value)) = line.split_once('=') else { + return Err(format!( + "PostgreSQL service file '{}', line {}: expected key=value", + path.display(), + line_number + 1 + )); + }; + options.insert(key.trim().to_ascii_lowercase(), value.trim().to_string()); + } + if !found { + return Err(format!( + "PostgreSQL service '{service_name}' was not found in '{}'", + path.display() + )); + } + options.remove("service"); + options.remove("servicefile"); + Ok(options) +} + +/// Returns libpq's per-user password-file location for supported Unix targets. +fn default_password_file() -> Option { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".pgpass")) + .filter(|path| path.is_file()) +} + +/// Finds the first matching `host:port:database:user:password` entry in `.pgpass`. +fn password_from_file( + path: &Path, + options: &BTreeMap, +) -> Result, String> { + if !path.is_file() { + return Ok(None); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(path) + .map_err(|error| format!("PostgreSQL passfile '{}': {error}", path.display()))? + .permissions() + .mode(); + if mode & 0o077 != 0 { + return Err(format!( + "PostgreSQL passfile '{}' must not be accessible by group or others", + path.display() + )); + } + } + let contents = fs::read_to_string(path) + .map_err(|error| format!("PostgreSQL passfile '{}': {error}", path.display()))?; + let raw_host = options + .get("host") + .or_else(|| options.get("hostaddr")) + .map(String::as_str) + .unwrap_or("localhost"); + let host = if raw_host.starts_with('/') { + "localhost" + } else { + raw_host + }; + let port = options.get("port").map(String::as_str).unwrap_or("5432"); + let database = options + .get("dbname") + .or_else(|| options.get("user")) + .map(String::as_str) + .unwrap_or(""); + let user = options.get("user").map(String::as_str).unwrap_or(""); + if host.contains(',') || port.contains(',') { + return Err( + "PostgreSQL passfile with multiple hosts or ports cannot be represented by the native client" + .to_string(), + ); + } + for line in contents.lines() { + let line = line.trim_end(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let fields = split_password_line(line)?; + if password_field_matches(&fields[0], host) + && password_field_matches(&fields[1], port) + && password_field_matches(&fields[2], database) + && password_field_matches(&fields[3], user) + { + return Ok(Some(fields[4].clone())); + } + } + Ok(None) +} + +/// Splits one `.pgpass` row while honoring backslash-escaped colons and slashes. +fn split_password_line(line: &str) -> Result, String> { + let mut fields = vec![String::new()]; + let mut escaped = false; + for character in line.chars() { + if escaped { + if !matches!(character, ':' | '\\') { + fields.last_mut().unwrap().push('\\'); + } + fields.last_mut().unwrap().push(character); + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == ':' && fields.len() < 5 { + fields.push(String::new()); + } else { + fields.last_mut().unwrap().push(character); + } + } + if escaped { + fields.last_mut().unwrap().push('\\'); + } + if fields.len() != 5 { + return Err("invalid PostgreSQL passfile entry: expected five colon-separated fields".to_string()); + } + Ok(fields) +} + +/// Applies `.pgpass` wildcard matching to one host, port, database, or user field. +fn password_field_matches(pattern: &str, value: &str) -> bool { + pattern == "*" || pattern == value +} + +/// Converts fully resolved options into the subset accepted by `postgres::Config`. +fn parse_resolved_dsn(options: &BTreeMap) -> Result { + // php-src's `pgsql_driver.c:1350` default connect timeout, in seconds. + const DEFAULT_CONNECT_TIMEOUT_SECS: u32 = 30; + const ACCEPTED_KEYS: &[&str] = &[ + "user", + "password", + "dbname", + "options", + "application_name", + "sslnegotiation", + "host", + "hostaddr", + "port", + "connect_timeout", + "tcp_user_timeout", + "keepalives", + "keepalives_idle", + "keepalives_interval", + "keepalives_retries", + "target_session_attrs", + "channel_binding", + "load_balance_hosts", + ]; + let mut parts: Vec = Vec::new(); + // F-PG-03: tracks whether the caller already bounded the connect (either + // straight in the DSN or via `ATTR_TIMEOUT`, which the prelude folds into the + // DSN under the very same key) — if so, that value wins over the 30 s default. + let mut saw_connect_timeout = false; + for (key, value) in options { + // The TLS keys are consumed by `parse_tls`/`open`, not by the libpq + // connection string: tokio-postgres's parser rejects `sslrootcert`/ + // `sslcert`/`sslkey` and the `verify-ca`/`verify-full` sslmode values, so + // forwarding any of them would make `.parse::()` fail. + if matches!( + key.as_str(), + "sslmode" + | "sslrootcert" + | "sslcert" + | "sslkey" + | "sslcertmode" + | "sslcrl" + | "sslcrldir" + | "sslsni" + | "ssl_min_protocol_version" + | "ssl_max_protocol_version" + ) { + continue; + } + if key == "client_encoding" { + validate_client_encoding(value)?; + continue; + } + if key == "gssencmode" { + if value == "disable" { + continue; + } + return Err(format!( + "unsupported PostgreSQL gssencmode '{value}': the native client has no GSSAPI transport" + )); + } + if !ACCEPTED_KEYS.contains(&key.as_str()) { + return Err(format!( + "unsupported PostgreSQL DSN option '{key}': elephc's native client cannot honor its libpq semantics" + )); + } + if key == "connect_timeout" { + saw_connect_timeout = true; + } + // F-CORE-02: the prelude percent-encodes '%' and ';' on a constructor-supplied + // `user`/`password` value before folding it into the DSN, so it survives the + // `body.split(';')` above intact instead of truncating at an embedded ';'. + // Undo that encoding here — and only for these two keys, since every other + // value is passed straight through byte-identical — before escaping it into + // the libpq conninfo string below. // libpq connection strings quote values containing spaces/specials; a // simple single-quote wrap with backslash-escaping is sufficient here. let escaped = value.replace('\\', "\\\\").replace('\'', "\\'"); parts.push(format!("{}='{}'", key, escaped)); } + // The resolver normally supplies libpq's OS-user default for a bare `pgsql:`. + // Keep this guard for environments with neither explicit/default user nor any + // other connection option; the timeout alone is not a usable identity. if parts.is_empty() { return Err("empty pgsql DSN".to_string()); } + // F-PG-03: bound an otherwise unbounded connect at php-src's 30 s (see the + // doc comment) — only when the caller gave no `connect_timeout` of their own. + if !saw_connect_timeout { + parts.push(format!("connect_timeout='{}'", DEFAULT_CONNECT_TIMEOUT_SECS)); + } Ok(parts.join(" ")) } +/// Validates a PostgreSQL client-encoding identifier before it is embedded in a +/// post-connect `SET client_encoding` command. +fn validate_client_encoding(value: &str) -> Result<(), String> { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(format!( + "invalid PostgreSQL client_encoding '{value}': expected an encoding identifier" + )); + } + Ok(()) +} + +/// Extracts the optional validated `client_encoding` DSN value. +#[cfg(test)] +fn client_encoding_from_dsn(dsn: &str) -> Result, String> { + let options = resolve_dsn_options(dsn)?; + client_encoding_from_options(&options) +} + +/// Extracts and validates `client_encoding` from already resolved options. +fn client_encoding_from_options( + options: &BTreeMap, +) -> Result, String> { + let Some(value) = options.get("client_encoding") else { + return Ok(None); + }; + validate_client_encoding(value)?; + Ok(Some(value.clone())) +} + +/// Percent-decodes a `user=`/`password=` DSN value (F-CORE-02). The prelude +/// percent-encodes '%' and ';' on the credential it folds into the DSN — '%' +/// first, so the '%' introduced by encoding ';' as `%3B` is not itself +/// re-encoded — precisely so a ';' or '%' embedded in the username/password +/// survives `body.split(';')` above instead of truncating the credential. +/// This undoes that encoding; a value with no '%' is returned unchanged +/// (byte-identical) without allocating a new string. An invalid or truncated +/// escape (not two hex digits) is copied through verbatim rather than +/// rejected, since a bare '%' is legal in a value that predates this scheme. +fn percent_decode_credential(raw: &str) -> String { + if !raw.contains('%') { + return raw.to_string(); + } + let b = raw.as_bytes(); + let mut out: Vec = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' + && i + 2 < b.len() + && b[i + 1].is_ascii_hexdigit() + && b[i + 2].is_ascii_hexdigit() + { + let hi = (b[i + 1] as char).to_digit(16).unwrap() as u8; + let lo = (b[i + 2] as char).to_digit(16).unwrap() as u8; + out.push((hi << 4) | lo); + i += 3; + } else { + out.push(b[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// The PostgreSQL TLS parameters carried by a `pgsql:` DSN, extracted separately +/// from the libpq connection string (see [`parse_dsn`]). `mode` mirrors libpq's +/// `sslmode`; the three optional paths mirror libpq's `sslrootcert` (server CA +/// bundle), `sslcert`, and `sslkey` (client-certificate mutual TLS). The path +/// fields are only read when the `tls` feature is compiled in; a +/// `--no-default-features` build still parses them (so the DSN is accepted) but +/// leaves them unused. +#[cfg_attr(not(feature = "tls"), allow(dead_code))] +struct PgTls { + /// Lowercased `sslmode` value; empty when the DSN omits it (libpq and + /// tokio-postgres both default to `prefer`). + mode: String, + /// `sslrootcert`: a PEM CA bundle the server certificate is verified against. + /// When absent, the bundled webpki-roots trust anchors are used. + root_cert: Option, + /// `sslcert`: the client certificate chain PEM for mutual TLS. + client_cert: Option, + /// `sslkey`: the client private-key PEM for mutual TLS. + client_key: Option, + /// libpq's policy for presenting a client certificate (`allow|disable|require`). + client_cert_mode: String, + /// PEM certificate-revocation-list file. + crl_file: Option, + /// Directory whose PEM CRLs are combined for rustls verification. + crl_directory: Option, + /// Whether the TLS ClientHello carries the host name (libpq `sslsni`). + server_name_indication: bool, + /// Lowest TLS protocol version accepted by libpq-style configuration. + min_protocol_version: Option, + /// Highest TLS protocol version accepted by libpq-style configuration. + max_protocol_version: Option, +} + +impl Default for PgTls { + /// Builds libpq-compatible TLS defaults (`sslmode=prefer`, SNI enabled). + fn default() -> Self { + Self { + mode: String::new(), + root_cert: None, + client_cert: None, + client_key: None, + client_cert_mode: "allow".to_string(), + crl_file: None, + crl_directory: None, + server_name_indication: true, + min_protocol_version: None, + max_protocol_version: None, + } + } +} + +/// Extracts the TLS parameters from a `pgsql:` DSN (the keys [`parse_dsn`] +/// deliberately drops). Unknown keys are ignored; a DSN without the `pgsql:` +/// prefix yields the default (unset) parameters. +#[cfg(test)] +fn parse_tls(dsn: &str) -> Result { + let options = resolve_dsn_options(dsn)?; + parse_tls_options(&options) +} + +/// Extracts and validates TLS settings from fully resolved connection options. +fn parse_tls_options(options: &BTreeMap) -> Result { + let mut tls = PgTls::default(); + if let Some(value) = options.get("sslmode") { + tls.mode = value.to_ascii_lowercase(); + } + if !matches!( + tls.mode.as_str(), + "" | "disable" | "allow" | "prefer" | "require" | "verify-ca" | "verify-full" + ) { + return Err(format!("invalid PostgreSQL sslmode '{}'", tls.mode)); + } + tls.root_cert = options.get("sslrootcert").cloned(); + tls.client_cert = options.get("sslcert").cloned(); + tls.client_key = options.get("sslkey").cloned(); + tls.crl_file = options.get("sslcrl").cloned(); + tls.crl_directory = options.get("sslcrldir").cloned(); + if let Some(value) = options.get("sslcertmode") { + tls.client_cert_mode = value.to_ascii_lowercase(); + } + if !matches!(tls.client_cert_mode.as_str(), "allow" | "disable" | "require") { + return Err(format!( + "invalid PostgreSQL sslcertmode '{}'", + tls.client_cert_mode + )); + } + if tls.client_cert_mode == "require" + && (tls.client_cert.is_none() || tls.client_key.is_none()) + { + return Err("PostgreSQL sslcertmode=require needs both sslcert and sslkey".to_string()); + } + if tls.client_cert_mode != "disable" + && (tls.client_cert.is_some() != tls.client_key.is_some()) + { + return Err("PostgreSQL client TLS authentication needs both sslcert and sslkey".to_string()); + } + if let Some(value) = options.get("sslsni") { + tls.server_name_indication = match value.as_str() { + "1" => true, + "0" => false, + _ => { + return Err(format!( + "invalid PostgreSQL sslsni value '{value}': expected 0 or 1" + )) + } + }; + } + tls.min_protocol_version = options.get("ssl_min_protocol_version").cloned(); + tls.max_protocol_version = options.get("ssl_max_protocol_version").cloned(); + validate_tls_protocol_range(&tls)?; + Ok(tls) +} + +/// Validates libpq TLS protocol bounds against rustls's TLS 1.2/1.3 support. +fn validate_tls_protocol_range(tls: &PgTls) -> Result<(), String> { + let min = tls + .min_protocol_version + .as_deref() + .filter(|value| !value.is_empty()) + .map(tls_protocol_rank) + .transpose()?; + let max = tls + .max_protocol_version + .as_deref() + .filter(|value| !value.is_empty()) + .map(tls_protocol_rank) + .transpose()?; + if matches!(min, Some(0 | 1)) || matches!(max, Some(0 | 1)) { + return Err("PostgreSQL TLS 1.0/1.1 cannot be honored by the rustls native client".to_string()); + } + if matches!((min, max), (Some(min), Some(max)) if min > max) { + return Err("PostgreSQL ssl_min_protocol_version exceeds ssl_max_protocol_version".to_string()); + } + Ok(()) +} + +/// Maps a libpq TLS protocol spelling to an ordered version rank. +fn tls_protocol_rank(value: &str) -> Result { + match value.to_ascii_uppercase().as_str() { + "TLSV1" | "TLSV1.0" => Ok(0), + "TLSV1.1" => Ok(1), + "TLSV1.2" => Ok(2), + "TLSV1.3" => Ok(3), + _ => Err(format!("invalid PostgreSQL TLS protocol version '{value}'")), + } +} + +/// Applies the DSN's `sslmode` to `config` and opens the connection, using rustls +/// (ring provider) when TLS is requested. `disable` connects in plaintext; +/// `prefer` (the default) attempts TLS but allows a plaintext session; +/// `require`/`verify-ca`/`verify-full` demand TLS. The rustls verifier always +/// validates the server certificate against the trust anchors (a stricter, safer +/// default than libpq's bare `require`, which encrypts without verifying); +/// `verify-ca` and `verify-full` therefore behave identically here. +#[cfg(feature = "tls")] +fn connect_tls( + config: &mut Config, + tls: &PgTls, + notices: Arc>>, +) -> Result { + use tokio_postgres::config::SslMode; + let runtime = RuntimeBuilder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())?; + if tls.mode == "disable" { + config.ssl_mode(SslMode::Disable); + let connected = runtime + .block_on(config.connect(NoTls)) + .map_err(|error| error.to_string())?; + return Ok(start_async_client(runtime, connected, notices)); + } + let require = matches!(tls.mode.as_str(), "require" | "verify-ca" | "verify-full"); + config.ssl_mode(if require { + SslMode::Require + } else { + SslMode::Prefer + }); + let connector = build_tls_connector(tls)?; + let connected = runtime + .block_on(config.connect(connector)) + .map_err(|error| error.to_string())?; + Ok(start_async_client(runtime, connected, notices)) +} + +/// The `--no-default-features` fallback: no TLS backend is linked, so a DSN that +/// *demands* TLS fails loudly rather than silently connecting in plaintext, while +/// `disable`/`prefer`/unset (which tolerate plaintext) still connect. +#[cfg(not(feature = "tls"))] +fn connect_tls( + config: &mut Config, + tls: &PgTls, + notices: Arc>>, +) -> Result { + if matches!(tls.mode.as_str(), "require" | "verify-ca" | "verify-full") { + return Err(format!( + "pgsql sslmode={} requires TLS, which was not compiled in \ + (rebuild elephc-pdo with its default `tls` feature)", + tls.mode + )); + } + let runtime = RuntimeBuilder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())?; + let connected = runtime + .block_on(config.connect(NoTls)) + .map_err(|error| error.to_string())?; + Ok(start_async_client(runtime, connected, notices)) +} + +/// Starts the connection driver on `runtime` and captures asynchronous notices +/// and notifications instead of letting the default future discard them. +fn start_async_client( + runtime: Runtime, + connected: (AsyncClient, Connection), + notices: Arc>>, +) -> Client +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let (inner, mut connection) = connected; + let (notification_tx, notification_rx) = mpsc::channel(); + runtime.spawn(async move { + loop { + let Some(message) = poll_fn(|context| connection.poll_message(context)).await else { + break; + }; + match message { + Ok(AsyncMessage::Notification(notification)) => { + let _ = notification_tx.send(PgNotification { + channel: notification.channel().to_string(), + process_id: notification.process_id(), + payload: notification.payload().to_string(), + }); + } + Ok(AsyncMessage::Notice(notice)) => { + if let Ok(mut queue) = notices.lock() { + queue.push_back(notice.message().to_string()); + } + } + Err(_) => break, + _ => {} + } + } + }); + Client { + runtime, + inner, + notifications: notification_rx, + } +} + +/// Builds a rustls `MakeRustlsConnect` for the pg connection. The `ClientConfig` +/// is built with an explicit ring `CryptoProvider` (`builder_with_provider`), so +/// it never depends on a process-global default provider. When `sslrootcert` is +/// given, only that PEM CA bundle is trusted; otherwise the bundled webpki-roots +/// anchors are used. `sslcert`+`sslkey` (both required together) enable +/// client-certificate mutual TLS. +#[cfg(feature = "tls")] +fn build_tls_connector(tls: &PgTls) -> Result { + use rustls::RootCertStore; + use std::sync::Arc; + + let mut roots = RootCertStore::empty(); + if let Some(ca) = &tls.root_cert { + for cert in load_certs(ca, "sslrootcert")? { + roots + .add(cert) + .map_err(|e| format!("sslrootcert {}: {}", ca, e))?; + } + } else { + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + } + + let min_rank = tls + .min_protocol_version + .as_deref() + .filter(|value| !value.is_empty()) + .map(tls_protocol_rank) + .transpose()? + .unwrap_or(2); + let max_rank = tls + .max_protocol_version + .as_deref() + .filter(|value| !value.is_empty()) + .map(tls_protocol_rank) + .transpose()? + .unwrap_or(3); + let mut protocol_versions: Vec<&'static rustls::SupportedProtocolVersion> = Vec::new(); + if min_rank <= 3 && max_rank >= 3 { + protocol_versions.push(&rustls::version::TLS13); + } + if min_rank <= 2 && max_rank >= 2 { + protocol_versions.push(&rustls::version::TLS12); + } + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let verifier_roots = Arc::new(roots.clone()); + let builder = rustls::ClientConfig::builder_with_provider(Arc::clone(&provider)) + .with_protocol_versions(&protocol_versions) + .map_err(|e| e.to_string())? + .with_root_certificates(roots); + + let mut config = match ( + tls.client_cert_mode.as_str(), + &tls.client_cert, + &tls.client_key, + ) { + ("disable", _, _) => builder.with_no_client_auth(), + (_, Some(cert), Some(key)) => { + let chain = load_certs(cert, "sslcert")?; + let der = load_private_key(key)?; + builder + .with_client_auth_cert(chain, der) + .map_err(|e| e.to_string())? + } + _ => builder.with_no_client_auth(), + }; + config.enable_sni = tls.server_name_indication; + let crls = load_crls(tls)?; + if !crls.is_empty() { + let verifier = rustls::client::WebPkiServerVerifier::builder_with_provider( + verifier_roots, + provider, + ) + .with_crls(crls) + .build() + .map_err(|error| format!("PostgreSQL TLS CRL configuration: {error}"))?; + config + .dangerous() + .set_certificate_verifier(verifier); + } + Ok(tokio_postgres_rustls::MakeRustlsConnect::new(config)) +} + +/// Loads CRLs from `sslcrl` and every regular file/symlink in `sslcrldir`. +#[cfg(feature = "tls")] +fn load_crls( + tls: &PgTls, +) -> Result>, String> { + let requested = tls.crl_file.is_some() || tls.crl_directory.is_some(); + let mut paths = Vec::new(); + if let Some(path) = &tls.crl_file { + paths.push(PathBuf::from(path)); + } + if let Some(directory) = &tls.crl_directory { + let mut entries = fs::read_dir(directory) + .map_err(|error| format!("sslcrldir {directory}: {error}"))? + .collect::, _>>() + .map_err(|error| format!("sslcrldir {directory}: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let metadata = entry + .metadata() + .map_err(|error| format!("sslcrldir '{}': {error}", entry.path().display()))?; + if metadata.is_file() { + paths.push(entry.path()); + } + } + } + let mut output = Vec::new(); + for path in paths { + let pem = fs::read(&path) + .map_err(|error| format!("PostgreSQL CRL '{}': {error}", path.display()))?; + let mut reader = &pem[..]; + for crl in rustls_pemfile::crls(&mut reader) { + output.push( + crl.map_err(|error| format!("PostgreSQL CRL '{}': {error}", path.display()))?, + ); + } + } + if requested && output.is_empty() { + return Err("PostgreSQL TLS CRL configuration contains no PEM CRLs".to_string()); + } + Ok(output) +} + +/// Reads a PEM file into a chain of DER certificates. `label` names the DSN key +/// for error messages (`sslrootcert` / `sslcert`). +#[cfg(feature = "tls")] +fn load_certs( + path: &str, + label: &str, +) -> Result>, String> { + let pem = std::fs::read(path).map_err(|e| format!("{} {}: {}", label, path, e))?; + let mut reader = &pem[..]; + let mut out = Vec::new(); + for cert in rustls_pemfile::certs(&mut reader) { + out.push(cert.map_err(|e| format!("{} {}: {}", label, path, e))?); + } + if out.is_empty() { + return Err(format!("{} {}: no certificates found", label, path)); + } + Ok(out) +} + +/// Reads the first PEM private key (PKCS#8, PKCS#1, or SEC1) from `sslkey`. +#[cfg(feature = "tls")] +fn load_private_key(path: &str) -> Result, String> { + let pem = std::fs::read(path).map_err(|e| format!("sslkey {}: {}", path, e))?; + let mut reader = &pem[..]; + rustls_pemfile::private_key(&mut reader) + .map_err(|e| format!("sslkey {}: {}", path, e))? + .ok_or_else(|| format!("sslkey {}: no private key found", path)) +} + +/// Returns whether `b` is an identifier byte (`[A-Za-z0-9_]`), used both to +/// read a placeholder name and to test the "word boundary" before a possible +/// `E'...'`/`e'...'` escape-string prefix. +/// +/// Deliberately ASCII-only: php-src's bind-name class really is +/// `BINDCHR = [:][a-zA-Z0-9_]+` (`pdo_sql_parser.re`), so a byte ≥ 0x80 ends a +/// `:name` rather than extending it. The dollar-quote *tag* classes are the wider +/// ones — see [`is_dolq_start`] / [`is_dolq_cont`], which must not be conflated +/// with this. +fn is_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +/// Returns whether `b` can OPEN a dollar-quote tag, per php-src's pgsql scanner +/// rule `DOLQ_START = [A-Za-z\200-\377_]` (`pgsql_sql_parser.re:32`). The +/// `\200-\377` half (every byte ≥ 0x80) is load-bearing, not decorative: +/// PostgreSQL's own lexer treats multibyte "letters" as identifier characters, so +/// `$café$ ... $café$` is a perfectly valid dollar-quoted string. Gating the tag +/// on `is_ascii_alphabetic()` left such a tag unrecognized, the quote never +/// opened, and the body fell through to the ordinary scanner — which then +/// rewrote any `?`/`:name` inside the *string literal* into a real bind +/// (F-PARSE-02). +fn is_dolq_start(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' || b >= 0x80 +} + +/// Returns whether `b` can CONTINUE a dollar-quote tag, per php-src's +/// `DOLQ_CONT = [A-Za-z\200-\377_0-9]` (`pgsql_sql_parser.re:33`) — [`is_dolq_start`] +/// plus the digits. Every continuation byte of a multi-byte UTF-8 character is +/// itself ≥ 0x80, so a tag scan driven by this predicate always stops on a char +/// boundary and the tag can be sliced back out of the `&str` safely. +fn is_dolq_cont(b: u8) -> bool { + is_dolq_start(b) || b.is_ascii_digit() +} + +/// Returns the byte length of the UTF-8 sequence led by `b` (1 for ASCII, 2-4 +/// for a multi-byte lead byte). `sql` is always valid UTF-8, so slicing +/// `&sql[i..i + utf8_len(bytes[i])]` lands on a valid char boundary at both +/// ends — used to copy a content byte (or run of one multi-byte codepoint) +/// through `out.push_str` instead of `out.push(b as char)`, which corrupts any +/// codepoint above U+007F: a `u8` cast to `char` treats each raw continuation +/// byte as its own Latin-1 codepoint and re-encodes it as 2 UTF-8 bytes, +/// doubling/mangling every multi-byte character embedded in the SQL text +/// (BUG 1). +fn utf8_len(b: u8) -> usize { + if b & 0x80 == 0 { + 1 + } else if b & 0xE0 == 0xC0 { + 2 + } else if b & 0xF0 == 0xE0 { + 3 + } else if b & 0xF8 == 0xF0 { + 4 + } else { + // A stray continuation byte can't start a codepoint in valid UTF-8; + // fall back to one byte so the scanner still makes forward progress. + 1 + } +} + +/// Scans a PostgreSQL single-quoted string or double-quoted identifier and returns +/// the exclusive end after its closing delimiter. `backslash_escapes` is enabled +/// only for a standalone `E'...'` prefix. `None` preserves php-src's scanner +/// backtracking contract for an unterminated region. +fn scan_pg_quoted_region( + bytes: &[u8], + start: usize, + quote: u8, + backslash_escapes: bool, +) -> Option { + let mut i = start + 1; + while i < bytes.len() { + if backslash_escapes && bytes[i] == b'\\' && i + 1 < bytes.len() { + i += 2; + continue; + } + if bytes[i] == quote { + if i + 1 < bytes.len() && bytes[i + 1] == quote { + i += 2; + continue; + } + return Some(i + 1); + } + i += 1; + } + None +} + /// Translates PDO `?` and `:name` placeholders to PostgreSQL `$N`, returning the -/// rewritten SQL and the bare-name → 1-based-index map. Single-quoted string -/// literals are passed through untouched, and the `::type` cast operator is not -/// mistaken for a named placeholder. -pub fn translate_placeholders(sql: &str) -> (String, HashMap) { +/// rewritten SQL, the bare-name → 1-based-index map, and whether the SQL mixed a +/// positional `?` with a named `:name` (PDO forbids this combination; `prepare()` +/// checks the flag and raises `HY093` before ever reaching the server). +/// +/// The scanner tracks these mutually exclusive regions, copying each verbatim +/// (never scanning `?`/`:name` inside them) before resuming normal placeholder +/// scanning: +/// - `-- ...` line comments (to end of line or EOF); +/// - `/* ... */` block comments (non-nested, to the first `*/` or EOF); +/// - `'...'` single-quoted strings, with `''` as the doubled-quote escape and, +/// when the string is `E'...'`/`e'...'`-prefixed (a standalone `E`/`e` token, +/// not part of a preceding identifier), `\'`/`\\` backslash escapes active too +/// (a plain `'...'` string only recognizes the `''` doubling, per +/// `standard_conforming_strings`); +/// - `"..."` double-quoted identifiers, with `""` as the doubled-quote escape; +/// - `$tag$...$tag$` dollar-quoted strings (`tag` is empty or matches php-src's +/// `DOLQ_START DOLQ_CONT*` — see [`is_dolq_start`] / [`is_dolq_cont`], which +/// accept non-ASCII bytes, so `$café$...$café$` opens a quote like PostgreSQL's +/// own lexer — and must be followed by `$` to open; a `$` immediately followed +/// by a digit, e.g. a literal `$1` in the input, can never start a tag and is +/// emitted as a plain `$`). +/// +/// A `??` (exactly two `?`) is PostgreSQL's jsonb `?`/`?|`/`?&` operator escape: +/// it collapses to a single literal `?` in the output and allocates no +/// placeholder slot. A lone `?` is a real positional placeholder. Symmetrically, a +/// run of two or more `:` — `::`, the cast operator, and any longer run — is a +/// single verbatim text token, never a named placeholder, and is consumed whole: +/// php-src's `MULTICHAR = [:]{2,}` is greedy, so eating colons pairwise would let +/// an odd run's last colon (`:::c`) be re-read as a phantom `:c` bind. `#` is not +/// a comment introducer in PostgreSQL. +/// +/// A `:name` immediately preceded by an alphanumeric byte is NOT a named +/// placeholder (matching php-src's `pdo_sql_parser.re`, which skips the same +/// way), most importantly so an array slice like `data[1:5]` is left +/// untouched instead of misreading `:5` as a bind parameter. +pub(crate) fn translate_placeholders_with_markers( + sql: &str, +) -> ( + String, + HashMap, + bool, + Vec<(usize, usize, usize)>, +) { let bytes = sql.as_bytes(); + let len = bytes.len(); let mut out = String::with_capacity(sql.len() + 8); let mut named: HashMap = HashMap::new(); let mut next_index: i64 = 1; let mut i = 0; - let mut in_string = false; - while i < bytes.len() { - let c = bytes[i] as char; - if in_string { - out.push(c); - if c == '\'' { - // Doubled '' is an escaped quote inside the literal. - if i + 1 < bytes.len() && bytes[i + 1] == b'\'' { + let mut saw_positional = false; + let mut saw_named = false; + let mut markers = Vec::new(); + while i < len { + let c = bytes[i]; + match c { + b'-' if i + 1 < len && bytes[i + 1] == b'-' => { + // Line comment: verbatim to the end of the line (exclusive of + // the newline itself, which the default arm then copies) or EOF. + let start = i; + let mut j = i + 2; + while j < len && bytes[j] != b'\n' { + j += 1; + } + out.push_str(&sql[start..j]); + i = j; + } + b'/' if i + 1 < len && bytes[i + 1] == b'*' => { + // Block comment: verbatim to the matching non-nested `*/`. + // An unterminated opener backtracks to the one-byte fallback, + // matching php-src's re2c scanner rather than swallowing EOF. + let start = i; + let mut j = i + 2; + while j + 1 < len && !(bytes[j] == b'*' && bytes[j + 1] == b'/') { + j += 1; + } + if j + 1 < len { + let end = j + 2; + out.push_str(&sql[start..end]); + i = end; + } else { + out.push('/'); + i += 1; + } + } + b'"' => { + if let Some(end) = scan_pg_quoted_region(bytes, i, b'"', false) { + out.push_str(&sql[i..end]); + i = end; + } else { + out.push('"'); + i += 1; + } + } + b'\'' => { + // A standalone `E`/`e` immediately before this quote (not part + // of a longer identifier) makes this an escape-string. + let is_e_prefixed = i > 0 + && (bytes[i - 1] == b'E' || bytes[i - 1] == b'e') + && (i < 2 || !is_ident_byte(bytes[i - 2])); + if let Some(end) = scan_pg_quoted_region(bytes, i, b'\'', is_e_prefixed) { + out.push_str(&sql[i..end]); + i = end; + } else { out.push('\''); - i += 2; - continue; + i += 1; } - in_string = false; } - i += 1; - continue; - } - match c { - '\'' => { - in_string = true; - out.push(c); - i += 1; + b'$' => { + // A `$` immediately followed by a digit can never open a + // dollar-quote tag; emit it verbatim (e.g. a literal `$1`). + if i + 1 < len && bytes[i + 1].is_ascii_digit() { + out.push('$'); + i += 1; + continue; + } + let mut j = i + 1; + if j < len && is_dolq_start(bytes[j]) { + j += 1; + while j < len && is_dolq_cont(bytes[j]) { + j += 1; + } + } + if j < len && bytes[j] == b'$' { + // `bytes[i+1..j]` is the (possibly empty) tag; the opening + // delimiter closes at `j` (its own `$`). + let tag = &sql[i + 1..j]; + let delim = format!("${}$", tag); + let open_end = j + 1; + match sql[open_end..].find(&delim) { + Some(rel) => { + let close_end = open_end + rel + delim.len(); + out.push_str(&sql[i..close_end]); + i = close_end; + } + None => { + // Unterminated dollar-quote: backtrack the opener and + // keep scanning its body for placeholders like php-src. + out.push('$'); + i += 1; + } + } + } else { + // Not a valid tag-open (e.g. a bare `$`); emit verbatim. + out.push('$'); + i += 1; + } } - '?' => { + b'?' => { + // `??` is the jsonb operator escape: a single literal `?`, no + // placeholder slot allocated. + if i + 1 < len && bytes[i + 1] == b'?' { + out.push('?'); + i += 2; + continue; + } + let marker_start = out.len(); out.push('$'); out.push_str(&next_index.to_string()); + markers.push((marker_start, out.len(), next_index as usize)); next_index += 1; + saw_positional = true; i += 1; } - ':' => { - // `::` is the cast operator, not a named placeholder. - if i + 1 < bytes.len() && bytes[i + 1] == b':' { - out.push_str("::"); - i += 2; + b':' => { + // A run of 2+ `:` (`::`, the cast operator, and any longer run) is a + // single verbatim text token, never a named placeholder — php-src's + // `MULTICHAR = [:]{2,}` rule (`pgsql_sql_parser.re:35`) is greedy + // (re2c's maximal munch swallows the whole contiguous run). The run + // must be consumed WHOLE: taking colons two at a time leaves the + // third one of an odd run (`:::c`) to be re-scanned as a fresh `:c`, + // conjuring a named placeholder php-src never emits. + let mut run_end = i + 1; + while run_end < len && bytes[run_end] == b':' { + run_end += 1; + } + if run_end - i >= 2 { + out.push_str(&sql[i..run_end]); + i = run_end; continue; } // Read the placeholder name (identifier chars after the colon). let start = i + 1; let mut j = start; - while j < bytes.len() { - let nc = bytes[j] as char; - if nc.is_ascii_alphanumeric() || nc == '_' { - j += 1; - } else { - break; - } + while j < len && is_ident_byte(bytes[j]) { + j += 1; } if j == start { // A bare colon (not a named placeholder); emit verbatim. @@ -256,62 +1641,608 @@ pub fn translate_placeholders(sql: &str) -> (String, HashMap) { i += 1; continue; } + // php-src's `pdo_sql_parser.re` only treats `:name` as a bind + // placeholder when the byte immediately before the colon is + // NOT alphanumeric (BUG 2). Without this, an array slice like + // `data[1:5]` misreads `:5` as a named placeholder. Emit the + // colon verbatim; the identifier bytes are then re-scanned as + // ordinary text by the default arm on the next iterations. + if i > 0 && bytes[i - 1].is_ascii_alphanumeric() { + out.push(':'); + i += 1; + continue; + } let name = &sql[start..j]; let index = *named.entry(name.to_string()).or_insert_with(|| { let idx = next_index; next_index += 1; idx }); + let marker_start = out.len(); out.push('$'); out.push_str(&index.to_string()); + markers.push((marker_start, out.len(), index as usize)); + saw_named = true; i = j; } - _ => { - out.push(c); - i += 1; + _ => { + // Copy the whole codepoint via a slice (BUG 1): `c as char` + // would corrupt any multi-byte UTF-8 character (e.g. an + // embedded `'café'`/`'Zürich'` byte outside a recognized + // quoted region — the ordinary/unquoted path). + let n = utf8_len(c).min(len - i); + out.push_str(&sql[i..i + n]); + i += n; + } + } + } + let mixed = saw_positional && saw_named; + (out, named, mixed, markers) +} + +/// Translates PDO placeholders to PostgreSQL `$N` markers for native prepares. +#[cfg(test)] +pub fn translate_placeholders(sql: &str) -> (String, HashMap, bool) { + let (translated, named, mixed, _) = translate_placeholders_with_markers(sql); + (translated, named, mixed) +} + +/// Renders a PostgreSQL literal for one emulated-prepare bind without allowing +/// value bytes to alter the surrounding SQL syntax. +fn emulated_bind_literal(bind: &Bind) -> String { + match bind { + Bind::Null => "NULL".to_string(), + Bind::Int(value) => value.to_string(), + Bind::Float(value) if value.is_finite() => value.to_string(), + Bind::Float(value) => format!("'{}'", value), + Bind::Text(value) => format!("'{}'", value.replace('\'', "''")), + Bind::Bytes(value) => { + let hex = value + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("decode('{hex}', 'hex')") + } + } +} + +/// Substitutes only markers generated by the PDO scanner, leaving any source +/// `$1` token untouched even when the same statement also contains PDO binds. +pub(crate) fn interpolate_emulated_sql( + sql: &str, + markers: &[(usize, usize, usize)], + binds: &[Bind], +) -> Result { + let mut out = String::with_capacity(sql.len() + binds.len() * 8); + let mut cursor = 0usize; + for &(start, end, bind_index) in markers { + let bind = binds.get(bind_index.saturating_sub(1)).ok_or_else(|| { + "Invalid parameter number: number of bound variables does not match number of tokens" + .to_string() + })?; + out.push_str(&sql[cursor..start]); + out.push_str(&emulated_bind_literal(bind)); + cursor = end; + } + out.push_str(&sql[cursor..]); + Ok(out) +} + +/// Extracts the 5-char SQLSTATE from a postgres driver error. `tokio_postgres` +/// (the `postgres` crate's async foundation) already parses the server's +/// `ErrorResponse` message and exposes its `SQLSTATE` field ('C' code) through +/// `Error::code()`, so no manual wire-protocol parsing is needed here. Errors +/// with no server-reported code (a connection/transport failure rather than a +/// query error) fall back to the generic `HY000`. +fn pg_sqlstate(e: &PgError) -> String { + e.code() + .map(|c| c.code().to_string()) + .unwrap_or_else(|| "HY000".to_string()) +} + +/// Owns a PostgreSQL client while iterating a native query one requested row at +/// a time, returning the same client when the stream ends or is closed. +fn run_pg_stream_worker( + client: Client, + statement: Statement, + params: Vec, + commands: mpsc::Receiver, + responses: mpsc::Sender, +) { + let Client { + runtime, + inner, + notifications, + } = client; + let result: Result<(), PgError> = (|| { + let refs: Vec<&(dyn ToSql + Sync)> = params + .iter() + .map(|param| param as &(dyn ToSql + Sync)) + .collect(); + let rows = runtime.block_on(inner.query_raw(&statement, refs))?; + pin_mut!(rows); + if responses.send(PgStreamResponse::Started).is_err() { + return Ok(()); + } + while let Ok(command) = commands.recv() { + match command { + PgStreamCommand::Next => match runtime.block_on(rows.try_next())? { + Some(row) => { + if responses + .send(PgStreamResponse::Row(Vec::new(), decode_row(&row))) + .is_err() + { + break; + } + } + None => break, + }, + PgStreamCommand::Close => break, + } + } + Ok(()) + })(); + let client = Client { + runtime, + inner, + notifications, + }; + match result { + Ok(()) => { + let _ = responses.send(PgStreamResponse::Finished(client, 0)); + } + Err(error) => { + let sqlstate = pg_sqlstate(&error); + let message = error.to_string(); + let _ = responses.send(PgStreamResponse::Failed(client, sqlstate, message)); + } + } +} + +/// Owns a PostgreSQL client while consuming one simple-protocol message per +/// caller request, preserving row-description and command-completion framing. +fn run_pg_simple_stream_worker( + client: Client, + sql: String, + commands: mpsc::Receiver, + responses: mpsc::Sender, +) { + let Client { + runtime, + inner, + notifications, + } = client; + let result: Result = (|| { + // php-src returns from execute() as soon as PQsendQuery has accepted the + // request; it does not wait for PostgreSQL's first protocol message. Let + // the owner return before polling the async request for the same timing. + if responses.send(PgStreamResponse::Started).is_err() { + return Ok(0); + } + let stream = runtime.block_on(inner.simple_query_raw(&sql))?; + pin_mut!(stream); + let mut columns = Vec::new(); + let mut changes = 0i64; + while let Ok(command) = commands.recv() { + match command { + PgStreamCommand::Close => break, + PgStreamCommand::Next => loop { + match runtime.block_on(stream.try_next())? { + Some(SimpleQueryMessage::RowDescription(description)) => { + columns = description + .iter() + .map(|column| column.name().to_string()) + .collect(); + } + Some(SimpleQueryMessage::Row(row)) => { + let cells = (0..row.len()) + .map(|index| match row.get(index) { + Some(value) => Cell::Text(value.to_string()), + None => Cell::Null, + }) + .collect(); + if responses + .send(PgStreamResponse::Row(columns.clone(), cells)) + .is_err() + { + return Ok(changes); + } + break; + } + Some(SimpleQueryMessage::CommandComplete(count)) => { + changes = count as i64; + } + Some(_) => {} + None => return Ok(changes), + } + }, + } + } + Ok(changes) + })(); + let client = Client { + runtime, + inner, + notifications, + }; + match result { + Ok(changes) => { + let _ = responses.send(PgStreamResponse::Finished(client, changes)); + } + Err(error) => { + let sqlstate = pg_sqlstate(&error); + let message = error.to_string(); + let _ = responses.send(PgStreamResponse::Failed(client, sqlstate, message)); + } + } +} + +/// The "native" (driver-specific) error code this driver reports as PDO's +/// `errorInfo()[1]` for every PostgreSQL failure — a deliberate, documented +/// divergence from php-src rather than an oversight (D-07). +/// +/// PostgreSQL has **no integer error code**. The wire protocol's `ErrorResponse` +/// message carries only string fields (severity, SQLSTATE, message, detail, hint, +/// position, …), and the SQLSTATE *is* the code — which PDO already surfaces as +/// `errorInfo()[0]` (see [`pg_sqlstate`]). Accordingly the `postgres` crate's +/// `Error`/`DbError` expose no integer at all: `DbError::code()` returns a +/// `SqlState` (the 5-char SQLSTATE string), and the only other numeric accessors +/// are the server's *source-file line* and the error's character *position* in the +/// query — neither is an error code. +/// +/// What php-src's pdo_pgsql puts in `errorInfo()[1]` is not a server code either: +/// it is libpq's client-side `ExecStatusType` enum, i.e. `PQresultStatus()` of the +/// failed `PGresult` (almost always `PGRES_FATAL_ERROR`). elephc's driver is the +/// pure-Rust `postgres` client, which has no libpq and no `PGresult`, so that value +/// simply does not exist here and could only be fabricated. +/// +/// This driver therefore reports a single non-zero "an error occurred" marker. Zero +/// is reserved for success: `errcode` doubles as the bridge's error flag (callers +/// such as `copy_out`'s empty-vs-failed disambiguation test `elephc_pdo_errcode()` +/// against 0), so the marker only has to be non-zero and stable. `1` also matches +/// the value `my.rs` uses for its driver-level `HY093` (mixed placeholder styles) +/// rejection, so the one error both drivers raise themselves reports the same +/// native code on both. +const PG_NATIVE_ERRCODE: i64 = 1; + +impl PgConn { + /// Connects to PostgreSQL for a `pgsql:` DSN. Returns the connection or an + /// error message for `last_open_error`. The connection is built through a + /// `Config` (rather than `Client::connect`) so a `notice_callback` can be + /// installed that buffers every server NOTICE into `notices` for + /// `Pdo\Pgsql::setNoticeCallback()`. + pub fn open(dsn: &str) -> Result { + let options = resolve_dsn_options(dsn)?; + let conn_str = parse_resolved_dsn(&options)?; + let client_encoding = client_encoding_from_options(&options)?; + let tls = parse_tls_options(&options)?; + let mut config: Config = conn_str.parse().map_err(|e: PgError| e.to_string())?; + let notices: Arc>> = Arc::new(Mutex::new(VecDeque::new())); + // Applies `sslmode` and opens the connection over rustls (ring) when TLS is + // requested, or plaintext otherwise (see `connect_tls`). + let mut client = connect_tls(&mut config, &tls, Arc::clone(¬ices))?; + if let Some(encoding) = client_encoding { + client + .batch_execute(&format!("SET client_encoding TO '{encoding}'")) + .map_err(|error| error.to_string())?; + } + Ok(PgConn { + client: PgClientSlot(Some(client)), + changes: 0, + errmsg: String::new(), + errcode: 0, + sqlstate: "00000".to_string(), + notices, + prefetch: true, + query_generation: 0, + active_stream: None, + next_stream_id: 0, + in_transaction: false, + }) + } + + /// Sets the default PostgreSQL prefetch/buffering mode for future statements. + pub fn set_prefetch(&mut self, prefetch: bool) -> i64 { + self.prefetch = prefetch; + 1 + } + + /// Starts a new query generation and returns the generation an unbuffered + /// statement must retain to remain readable. + fn begin_query(&mut self) -> u64 { + self.finish_active_stream(); + self.query_generation = self.query_generation.wrapping_add(1).max(1); + self.query_generation + } + + /// Stops and drains ownership from the active worker before another command. + fn finish_active_stream(&mut self) { + let Some(mut active) = self.active_stream.take() else { + return; + }; + let _ = active.commands.send(PgStreamCommand::Close); + while let Ok(response) = active.responses.recv() { + match response { + PgStreamResponse::Finished(client, changes) => { + self.client.0 = Some(client); + self.changes = changes; + break; + } + PgStreamResponse::Failed(client, sqlstate, message) => { + self.client.0 = Some(client); + self.sqlstate = sqlstate; + self.errmsg = message; + self.errcode = PG_NATIVE_ERRCODE; + break; + } + PgStreamResponse::Started | PgStreamResponse::Row(_, _) => {} + } + } + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + } + + /// Finishes the active worker only when it belongs to `id`. + fn finish_stream(&mut self, id: u64) { + if self.active_stream.as_ref().map(|stream| stream.id) == Some(id) { + self.finish_active_stream(); + } + } + + /// Starts a demand-driven native query worker and returns its stream identity. + fn start_stream(&mut self, statement: Statement, params: Vec) -> Result { + self.finish_active_stream(); + let Some(client) = self.client.0.take() else { + self.errcode = PG_NATIVE_ERRCODE; + self.sqlstate = "HY000".to_string(); + self.errmsg = "PostgreSQL connection is busy with an unbuffered query".to_string(); + return Err(-1); + }; + let (command_tx, command_rx) = mpsc::channel(); + let (response_tx, response_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + run_pg_stream_worker(client, statement, params, command_rx, response_tx); + }); + self.next_stream_id = self.next_stream_id.wrapping_add(1).max(1); + let id = self.next_stream_id; + let mut active = PgActiveStream { + id, + commands: command_tx, + responses: response_rx, + worker: Some(worker), + }; + match active.responses.recv() { + Ok(PgStreamResponse::Started) => { + self.active_stream = Some(active); + Ok(id) + } + Ok(PgStreamResponse::Failed(client, sqlstate, message)) => { + self.client.0 = Some(client); + self.sqlstate = sqlstate; + self.errmsg = message; + self.errcode = PG_NATIVE_ERRCODE; + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + Err(-1) + } + Ok(PgStreamResponse::Finished(client, changes)) => { + self.client.0 = Some(client); + self.changes = changes; + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + Err(-1) + } + Ok(PgStreamResponse::Row(_, _)) | Err(_) => { + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + self.errcode = PG_NATIVE_ERRCODE; + self.sqlstate = "HY000".to_string(); + self.errmsg = "PostgreSQL unbuffered query worker terminated unexpectedly".to_string(); + Err(-1) + } + } + } + + /// Starts a demand-driven simple-protocol worker for PHP 8.5+ emulated queries. + fn start_simple_stream(&mut self, sql: String) -> Result { + self.finish_active_stream(); + let Some(client) = self.client.0.take() else { + self.errcode = PG_NATIVE_ERRCODE; + self.sqlstate = "HY000".to_string(); + self.errmsg = "PostgreSQL connection is busy with an unbuffered query".to_string(); + return Err(-1); + }; + let (command_tx, command_rx) = mpsc::channel(); + let (response_tx, response_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + run_pg_simple_stream_worker(client, sql, command_rx, response_tx); + }); + self.next_stream_id = self.next_stream_id.wrapping_add(1).max(1); + let id = self.next_stream_id; + let mut active = PgActiveStream { + id, + commands: command_tx, + responses: response_rx, + worker: Some(worker), + }; + match active.responses.recv() { + Ok(PgStreamResponse::Started) => { + self.active_stream = Some(active); + Ok(id) + } + Ok(PgStreamResponse::Failed(client, sqlstate, message)) => { + self.client.0 = Some(client); + self.sqlstate = sqlstate; + self.errmsg = message; + self.errcode = PG_NATIVE_ERRCODE; + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + Err(-1) + } + Ok(PgStreamResponse::Finished(client, changes)) => { + self.client.0 = Some(client); + self.changes = changes; + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + Ok(id) + } + Ok(PgStreamResponse::Row(_, _)) | Err(_) => { + self.errcode = PG_NATIVE_ERRCODE; + self.sqlstate = "HY000".to_string(); + self.errmsg = "PostgreSQL simple-query worker terminated unexpectedly".to_string(); + Err(-1) + } + } + } + + /// Requests one row from the active stream, recovering the client at EOF. + fn next_stream_row(&mut self, id: u64) -> Result, Vec)>, i64> { + let Some(active) = self.active_stream.as_mut() else { + return Ok(None); + }; + if active.id != id { + return Ok(None); + } + if active.commands.send(PgStreamCommand::Next).is_err() { + self.sqlstate = "HY000".to_string(); + self.errmsg = "PostgreSQL row stream is unavailable".to_string(); + self.errcode = PG_NATIVE_ERRCODE; + return Err(-1); + } + match active.responses.recv() { + Ok(PgStreamResponse::Row(columns, row)) => Ok(Some((columns, row))), + Ok(PgStreamResponse::Finished(client, changes)) => { + self.client.0 = Some(client); + self.changes = changes; + let mut active = self.active_stream.take().expect("active stream disappeared"); + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + Ok(None) + } + Ok(PgStreamResponse::Failed(client, sqlstate, message)) => { + self.client.0 = Some(client); + self.sqlstate = sqlstate; + self.errmsg = message; + self.errcode = PG_NATIVE_ERRCODE; + let mut active = self.active_stream.take().expect("active stream disappeared"); + if let Some(worker) = active.worker.take() { + let _ = worker.join(); + } + Err(-1) + } + Ok(PgStreamResponse::Started) | Err(_) => { + self.sqlstate = "HY000".to_string(); + self.errmsg = "PostgreSQL row stream returned an invalid response".to_string(); + self.errcode = PG_NATIVE_ERRCODE; + Err(-1) } } } - (out, named) -} -impl PgConn { - /// Connects to PostgreSQL for a `pgsql:` DSN. Returns the connection or an - /// error message for `last_open_error`. - pub fn open(dsn: &str) -> Result { - let conn_str = parse_dsn(dsn)?; - let client = Client::connect(&conn_str, NoTls).map_err(|e| e.to_string())?; - Ok(PgConn { - client, - changes: 0, - errmsg: String::new(), - errcode: 0, - }) + /// Removes and returns the oldest buffered server NOTICE message text, or an empty + /// string when none is pending. Backs `Pdo\Pgsql::setNoticeCallback()`: the prelude + /// drains this after each `exec()`/`query()` and dispatches each message to the + /// registered PHP callback. + pub fn drain_notice(&self) -> String { + self.notices + .lock() + .ok() + .and_then(|mut queue| queue.pop_front()) + .unwrap_or_default() + } + + /// Applies PHP 8.6's persistent-disconnect cleanup. PostgreSQL requires + /// `DISCARD ALL` outside a transaction, so a standalone rollback is sent first. + pub fn discard_all(&mut self) { + self.begin_query(); + let _ = self.client.batch_execute("ROLLBACK"); + if let Err(error) = self.client.batch_execute("DISCARD ALL") { + self.fail(error); + return; + } + if let Ok(mut notices) = self.notices.lock() { + notices.clear(); + } + self.changes = 0; + self.errmsg.clear(); + self.errcode = 0; + self.sqlstate = "00000".to_string(); + self.prefetch = true; + self.in_transaction = false; + self.begin_query(); + } + + /// Updates transaction bookkeeping after one successful SQL command. + fn note_transaction_sql(&mut self, sql: &str) { + self.in_transaction = transaction_state_after_sql(sql, self.in_transaction); } - /// Records an error message + a generic non-zero code, returning `-1`. - fn fail(&mut self, e: postgres::Error) -> i64 { + /// Records a server/transport error: its SQLSTATE (`errorInfo()[0]`), its message + /// (`errorInfo()[2]`) and the driver's single native error code + /// ([`PG_NATIVE_ERRCODE`], `errorInfo()[1]` — PostgreSQL has no integer code, see + /// the constant). Returns `-1`, the failure value of the row-count-returning + /// entry points. Every error path of this driver funnels through here or through + /// [`Self::fail_local`], so the native code is set in exactly those two places. + fn fail(&mut self, e: PgError) -> i64 { + self.sqlstate = pg_sqlstate(&e); self.errmsg = e.to_string(); - self.errcode = 1; + self.errcode = PG_NATIVE_ERRCODE; -1 } + /// Records a failure the *driver itself* raises, with no `postgres::Error` behind + /// it (the scanner's `HY093` rejection of a SQL text mixing `?` and `:name`), under + /// the same native error code as a server error ([`PG_NATIVE_ERRCODE`]). Returns + /// the recorded message, so a caller can `return Err(self.fail_local(…))`. + fn fail_local(&mut self, sqlstate: &str, msg: &str) -> String { + self.sqlstate = sqlstate.to_string(); + self.errmsg = msg.to_string(); + self.errcode = PG_NATIVE_ERRCODE; + self.errmsg.clone() + } + /// Runs a statement with no result rows (`PDO::exec`), returning the affected /// row count or `-1`. pub fn exec(&mut self, sql: &str) -> i64 { - // execute() runs a single command; fall back to batch_execute for - // multi-statement scripts (returning 0 affected, as PHP does for those). + self.begin_query(); + // execute() runs a single command; fall back to a multi-statement path for + // scripts execute() rejects (it only accepts exactly one command). match self.client.execute(sql, &[]) { Ok(n) => { self.changes = n as i64; self.errcode = 0; + self.sqlstate = "00000".to_string(); + self.note_transaction_sql(sql); n as i64 } - Err(_) => match self.client.batch_execute(sql) { - Ok(()) => { - self.changes = 0; + // P2-j: `simple_query` (not `batch_execute`) runs the whole script over + // the simple query protocol and yields one `SimpleQueryMessage` per + // statement, including a `CommandComplete(rows)` tag for each — mirroring + // php-src's `PQexec`, which reports the LAST command's row count for a + // multi-statement string. `batch_execute` discards those tags entirely + // (always 0 affected), which is what this replaces. + Err(_) => match self.client.simple_query(sql) { + Ok(messages) => { + let rows = messages + .iter() + .rev() + .find_map(|m| match m { + SimpleQueryMessage::CommandComplete(n) => Some(*n), + _ => None, + }) + .unwrap_or(0); + self.changes = rows as i64; self.errcode = 0; - 0 + self.sqlstate = "00000".to_string(); + self.note_transaction_sql(sql); + rows as i64 } Err(e) => self.fail(e), }, @@ -320,11 +2251,14 @@ impl PgConn { /// Runs a bare transaction-control statement, returning `1`/`0`. pub fn exec_simple(&mut self, sql: &str) -> i64 { + self.begin_query(); match self.client.batch_execute(sql) { - Ok(()) => 1, + Ok(()) => { + self.note_transaction_sql(sql); + 1 + } Err(e) => { - self.errmsg = e.to_string(); - self.errcode = 1; + self.fail(e); 0 } } @@ -333,6 +2267,7 @@ impl PgConn { /// Returns the last inserted id: `currval('name')` when a sequence name is /// given, else `lastval()` for the session. Returns `0` on error. pub fn last_insert_id(&mut self, name: Option<&str>) -> i64 { + self.begin_query(); let sql = match name { Some(n) if !n.is_empty() => { format!("SELECT currval('{}')", n.replace('\'', "''")) @@ -345,10 +2280,365 @@ impl PgConn { } } + /// Like `last_insert_id`, but returns the sequence value as PostgreSQL's text + /// representation instead of parsing it as an `i64`: PostgreSQL sequences are + /// `bigint` by default but a caller-chosen sequence can be any integer type, + /// so a text round-trip avoids a lossy/failing numeric bridge. Empty string on + /// error. + /// + /// F-CORE-18: an empty string is also the prelude's failure sentinel for + /// `PDO::lastInsertId()` (`string|false`), so a server error (most commonly + /// `lastval()`'s SQLSTATE 55000 when no sequence has been used yet in this + /// session) records the connection's real SQLSTATE/message/native code via + /// [`Self::fail`] before returning empty, instead of swallowing it — the + /// prelude reads `elephc_pdo_sqlstate`/`elephc_pdo_errmsg` right after this + /// call to decide between surfacing that error and a generic `IM001`. + pub fn last_insert_id_text(&mut self, name: Option<&str>) -> String { + self.begin_query(); + let sql = match name { + Some(n) if !n.is_empty() => { + format!("SELECT currval('{}')::text", n.replace('\'', "''")) + } + _ => "SELECT lastval()::text".to_string(), + }; + match self.client.query_one(&sql, &[]) { + Ok(row) => row.try_get::<_, String>(0).unwrap_or_default(), + Err(e) => { + self.fail(e); + String::new() + } + } + } + + /// Returns the PostgreSQL server's reported version string (`SHOW + /// server_version`), or an empty string if the query fails. + pub fn server_version(&mut self) -> String { + self.begin_query(); + match self.client.query_one("SHOW server_version", &[]) { + Ok(row) => row.try_get::<_, String>(0).unwrap_or_default(), + Err(_) => String::new(), + } + } + + /// Returns the linked pure-Rust PostgreSQL client implementation and version, + /// the standalone equivalent of php-src's compile-time libpq version. + pub fn client_version(&self) -> String { + "postgres 0.19.13".to_string() + } + + /// Maps the synchronous client's live closed state to php-src's observable + /// `PQstatus()` strings. A connected synchronous client is never exposed in + /// one of libpq's asynchronous handshake states. + pub fn connection_status(&self) -> String { + if self.is_closed() { + "Bad connection.".to_string() + } else { + "Connection OK; waiting to send.".to_string() + } + } + + /// Reports whether the underlying client is closed; an active stream owns a + /// live client temporarily and therefore still counts as connected. + pub fn is_closed(&self) -> bool { + self.client + .0 + .as_ref() + .map(Client::is_closed) + .unwrap_or(false) + } + + /// Builds php-src's PostgreSQL server-information string from live backend + /// and session parameters. + pub fn server_info(&mut self) -> String { + self.begin_query(); + let row = match self.client.query_one( + "SELECT pg_backend_pid(), current_setting('client_encoding'), current_setting('is_superuser'), current_setting('session_authorization'), current_setting('DateStyle')", + &[], + ) { + Ok(row) => row, + Err(_) => return String::new(), + }; + let pid = row.try_get::<_, i32>(0).unwrap_or(0); + let client_encoding = row.try_get::<_, String>(1).unwrap_or_default(); + let is_superuser = row.try_get::<_, String>(2).unwrap_or_default(); + let session_authorization = row.try_get::<_, String>(3).unwrap_or_default(); + let date_style = row.try_get::<_, String>(4).unwrap_or_default(); + format!( + "PID: {pid}; Client Encoding: {client_encoding}; Is Superuser: {is_superuser}; Session Authorization: {session_authorization}; Date Style: {date_style}" + ) + } + + /// Returns the PostgreSQL backend process id serving this connection + /// (`SELECT pg_backend_pid()`), or 0 if the query fails. Backs + /// `Pdo\Pgsql::getPid()`. + pub fn backend_pid(&mut self) -> i64 { + self.begin_query(); + match self.client.query_one("SELECT pg_backend_pid()", &[]) { + Ok(row) => row.try_get::<_, i32>(0).map(i64::from).unwrap_or(0), + Err(_) => 0, + } + } + + /// Creates a new empty large object and returns its OID as a decimal string + /// (`SELECT lo_create(0)`), or an empty string on error. Backs + /// `Pdo\Pgsql::lobCreate()`. + pub fn lob_create(&mut self) -> String { + self.begin_query(); + match self.client.query_one("SELECT lo_create(0)", &[]) { + Ok(row) => row + .try_get::<_, u32>(0) + .map(|oid| oid.to_string()) + .unwrap_or_default(), + Err(_) => String::new(), + } + } + + /// Deletes the large object named by `oid` (`SELECT lo_unlink()`), returning + /// 1 on success and 0 on a non-numeric OID or a server error. Backs + /// `Pdo\Pgsql::lobUnlink()`. + pub fn lob_unlink(&mut self, oid: &str) -> i64 { + self.begin_query(); + let Ok(oid_num) = oid.parse::() else { + return 0; + }; + // oid_num is a validated integer, so inlining it is injection-safe. + match self + .client + .query_one(&format!("SELECT lo_unlink({oid_num})"), &[]) + { + Ok(_) => 1, + Err(_) => 0, + } + } + + /// Reads a large object whole (`SELECT lo_get()`), returning its raw bytes, + /// or `None` on a non-numeric OID or a server error (e.g. no such object). Unlike + /// the descriptor-based `lo_open`/`lo_read`/`lo_close` API, `lo_get` runs + /// standalone (no explicit transaction). Retained for the pre-v45 bridge ABI; + /// `Pdo\Pgsql::lobOpen()` now uses bounded reads. + pub fn lob_get(&mut self, oid: &str) -> Option> { + self.begin_query(); + let oid_num = oid.parse::().ok()?; + // oid_num is a validated integer, so inlining it is injection-safe. + match self + .client + .query_one(&format!("SELECT lo_get({oid_num})"), &[]) + { + Ok(row) => match row.try_get::<_, Vec>(0) { + Ok(bytes) => { + self.errcode = 0; + self.sqlstate = "00000".to_string(); + Some(bytes) + } + Err(_) => None, + }, + Err(e) => { + self.fail(e); + None + } + } + } + + /// Writes a complete large-object value at offset zero for pre-v45 ABI callers. + /// The current stream wrapper uses [`Self::lob_write_at`] for bounded patches. + pub fn lob_put(&mut self, oid: &str, data: &[u8]) -> i64 { + self.begin_query(); + let Ok(oid_num) = oid.parse::() else { + return 0; + }; + match self + .client + .query_one("SELECT lo_put($1, 0, $2)", &[&oid_num, &data]) + { + Ok(_) => { + self.errcode = 0; + self.sqlstate = "00000".to_string(); + 1 + } + Err(e) => { + self.fail(e); + 0 + } + } + } + + /// Returns the current byte length of a PostgreSQL large object without + /// transferring its contents to the client, or `None` for an invalid/missing OID. + pub fn lob_size(&mut self, oid: &str) -> Option { + self.begin_query(); + let oid_num = oid.parse::().ok()?; + let sql = "SELECT COALESCE(MAX(l.pageno::bigint * 2048 + octet_length(l.data)), 0)::bigint FROM pg_catalog.pg_largeobject_metadata m LEFT JOIN pg_catalog.pg_largeobject l ON l.loid = m.oid WHERE m.oid = $1 GROUP BY m.oid"; + match self.client.query_opt(sql, &[&oid_num]) { + Ok(Some(row)) => { + self.errcode = 0; + self.sqlstate = "00000".to_string(); + row.try_get::<_, i64>(0).ok() + } + Ok(None) => None, + Err(error) => { + self.fail(error); + None + } + } + } + + /// Reads at most `length` bytes from a PostgreSQL large object at `offset`. + /// The server returns only the requested slice, avoiding a whole-object snapshot. + pub fn lob_read_at(&mut self, oid: &str, offset: i64, length: i64) -> Option> { + self.begin_query(); + let oid_num = oid.parse::().ok()?; + let length = i32::try_from(length).ok()?; + if offset < 0 || length < 0 { + return None; + } + match self + .client + .query_one("SELECT lo_get($1, $2, $3)", &[&oid_num, &offset, &length]) + { + Ok(row) => { + self.errcode = 0; + self.sqlstate = "00000".to_string(); + row.try_get::<_, Vec>(0).ok() + } + Err(error) => { + self.fail(error); + None + } + } + } + + /// Writes one byte slice to a PostgreSQL large object at `offset`, preserving + /// the server's native sparse-extension and zero-fill behavior. + pub fn lob_write_at(&mut self, oid: &str, offset: i64, data: &[u8]) -> i64 { + self.begin_query(); + let Ok(oid_num) = oid.parse::() else { + return -1; + }; + if offset < 0 { + return -1; + } + match self + .client + .query_one("SELECT lo_put($1, $2, $3)", &[&oid_num, &offset, &data]) + { + Ok(_) => { + self.errcode = 0; + self.sqlstate = "00000".to_string(); + data.len() as i64 + } + Err(error) => { + self.fail(error); + -1 + } + } + } + + /// Streams `data` into the server for a `COPY … FROM STDIN` statement (built by + /// the prelude), returning the number of rows copied or -1 on error. Backs + /// `Pdo\Pgsql::copyFromArray()` / `copyFromFile()`. + pub fn copy_in(&mut self, copy_sql: &str, data: &[u8]) -> i64 { + self.begin_query(); + let result = self.client.copy_in_bytes(copy_sql, data); + match result { + Ok(rows) => { + self.errcode = 0; + self.sqlstate = "00000".to_string(); + rows as i64 + } + Err(e) => self.fail(e), + } + } + + /// Runs a `COPY … TO STDOUT` statement (built by the prelude) and returns its raw + /// text output (rows separated by newlines); also an empty string on error, same + /// as for a genuinely empty COPY. Backs `Pdo\Pgsql::copyToArray()` / `copyToFile()`. + /// + /// P2-i: those two empty-string cases are told apart not by this return value + /// but by `errcode`, which this method always resets to `0` on success (even an + /// empty one) and sets non-zero via [`Self::fail`] on error — the prelude checks + /// `elephc_pdo_errcode()` immediately after the call to distinguish "really + /// empty" (returns `[]`) from "the COPY failed" (returns `false`), matching the + /// stub's `array|false` contract for `copyToArray()`. + pub fn copy_out(&mut self, copy_sql: &str) -> String { + self.begin_query(); + let result = self.client.copy_out_bytes(copy_sql); + match result { + Ok(buf) => { + self.errcode = 0; + self.sqlstate = "00000".to_string(); + String::from_utf8_lossy(&buf).into_owned() + } + Err(e) => { + self.fail(e); + String::new() + } + } + } + + /// Polls for a pending LISTEN/NOTIFY notification, returning it as a + /// tab-separated `channel\tpid\tpayload` string, or an empty string if none + /// arrives within `timeout_ms` (a zero/negative timeout polls once for an + /// already-buffered notification). Backs `Pdo\Pgsql::getNotify()`; the prelude + /// shapes the parts into the requested array form. + pub fn get_notify(&mut self, timeout_ms: i64) -> String { + use std::time::Duration; + self.begin_query(); + let timeout = Duration::from_millis(timeout_ms.max(0) as u64); + self.client + .notification(timeout) + .map(|notification| { + format!( + "{}\t{}\t{}", + notification.channel, notification.process_id, notification.payload + ) + }) + .unwrap_or_default() + } + /// Prepares a statement: translates placeholders and prepares it server-side - /// for column metadata. Returns the statement or an error message. - pub fn prepare(&mut self, sql: &str) -> Result { - let (translated, named_map) = translate_placeholders(sql); + /// for column metadata. Returns the statement or an error message. Rejects a + /// SQL text that mixes a positional `?` with a named `:name` placeholder + /// with `HY093` before ever asking the server to prepare it — PDO forbids + /// combining the two styles in one statement, and the server has no notion + /// of "named" placeholders to catch this itself. + pub fn prepare(&mut self, sql: &str, emulated: bool) -> Result { + self.begin_query(); + let (translated, named_map, mixed, markers) = translate_placeholders_with_markers(sql); + if mixed { + return Err(self.fail_local( + "HY093", + "Invalid parameter number: mixed named and positional parameters", + )); + } + if emulated { + let n_params = markers + .iter() + .map(|(_, _, index)| *index) + .max() + .unwrap_or(0); + self.errcode = 0; + self.sqlstate = "00000".to_string(); + return Ok(PgStmt { + conn_id: 0, + query_string: sql.to_string(), + statement: None, + emulated_sql: Some(translated), + emulated_markers: markers, + sent_sql: String::new(), + named_map, + binds: vec![Bind::Null; n_params], + bound: vec![false; n_params], + col_names: Vec::new(), + col_tables: Vec::new(), + rows: Vec::new(), + cursor: -1, + executed: false, + buffered: self.prefetch, + query_generation: 0, + stream_id: None, + simple_streaming: false, + }); + } match self.client.prepare(&translated) { Ok(statement) => { let col_names = statement @@ -357,27 +2647,94 @@ impl PgConn { .map(|c| c.name().to_string()) .collect(); let n_params = statement.params().len(); + let col_tables = statement + .columns() + .iter() + .map(|column| { + let Some(oid) = column.table_oid() else { + return String::new(); + }; + self.client + .query_opt( + "SELECT relname FROM pg_catalog.pg_class WHERE oid = $1", + &[&oid], + ) + .ok() + .flatten() + .and_then(|row| row.try_get::<_, String>(0).ok()) + .unwrap_or_default() + }) + .collect(); + self.errcode = 0; + self.sqlstate = "00000".to_string(); Ok(PgStmt { conn_id: 0, - statement, + query_string: sql.to_string(), + statement: Some(statement), + emulated_sql: None, + emulated_markers: Vec::new(), + sent_sql: String::new(), named_map, binds: vec![Bind::Null; n_params], + bound: vec![false; n_params], col_names, + col_tables, rows: Vec::new(), cursor: -1, executed: false, + buffered: self.prefetch, + query_generation: 0, + stream_id: None, + simple_streaming: false, }) } Err(e) => { - self.errmsg = e.to_string(); - self.errcode = 1; - Err(e.to_string()) + let msg = e.to_string(); + self.fail(e); + Err(msg) } } } } +/// Derives PostgreSQL transaction state after a successful transaction-control +/// command, preserving the current state for ordinary statements and savepoint rollback. +pub(crate) fn transaction_state_after_sql(sql: &str, current: bool) -> bool { + let normalized = sql.trim_start().to_ascii_uppercase(); + if normalized.starts_with("BEGIN") || normalized.starts_with("START TRANSACTION") { + return true; + } + if normalized.starts_with("COMMIT") || normalized.starts_with("END") { + return normalized.contains("AND CHAIN"); + } + if normalized.starts_with("ROLLBACK") { + if normalized.starts_with("ROLLBACK TO") { + return current; + } + return normalized.contains("AND CHAIN"); + } + current +} + impl PgStmt { + /// Enables PHP 8.5+'s lazy libpq-style behavior for simple-protocol statements. + pub fn enable_simple_streaming(&mut self) -> i64 { + if self.executed { + return 0; + } + self.simple_streaming = true; + 1 + } + /// Overrides this statement's buffering mode from prepare-time + /// `PDO::ATTR_PREFETCH`, before its first execution. + pub fn set_prefetch(&mut self, prefetch: bool) -> i64 { + if self.executed { + return 0; + } + self.buffered = prefetch; + 1 + } + /// Resolves a named placeholder to its 1-based index (0 if unknown). The /// leading colon is optional. pub fn bind_parameter_index(&self, name: &str) -> i64 { @@ -391,29 +2748,43 @@ impl PgStmt { return 0; } self.binds[(idx - 1) as usize] = value; + self.bound[(idx - 1) as usize] = true; 1 } /// Resets the cursor and execution state, keeping the bound values. - pub fn reset(&mut self) -> i64 { + pub fn reset(&mut self, conn: &mut PgConn) -> i64 { + if let Some(stream_id) = self.stream_id { + conn.finish_stream(stream_id); + } self.cursor = -1; self.executed = false; self.rows.clear(); + self.stream_id = None; 1 } /// Clears all bound values back to NULL. pub fn clear_bindings(&mut self) -> i64 { - for b in &mut self.binds { + for (b, bound) in self.binds.iter_mut().zip(self.bound.iter_mut()) { *b = Bind::Null; + *bound = false; } 1 } - /// Executes the query (once) and materializes the result set into decoded - /// cells. Sets `conn.changes` for non-result statements. + /// Executes the query once, buffering rows or starting a native demand stream. + /// Sets `conn.changes` for non-result statements. fn execute(&mut self, conn: &mut PgConn) -> Result<(), i64> { - let param_types: Vec = self.statement.params().to_vec(); + self.query_generation = conn.begin_query(); + if self.emulated_sql.is_some() { + return self.execute_emulated(conn); + } + let statement = self + .statement + .as_ref() + .expect("native PostgreSQL statement missing its prepared handle"); + let param_types: Vec = statement.params().to_vec(); let params: Vec = self .binds .iter() @@ -423,50 +2794,176 @@ impl PgStmt { ty, }) .collect(); + if !self.buffered && !statement.columns().is_empty() { + let stream_id = conn.start_stream(statement.clone(), params)?; + self.rows.clear(); + self.cursor = -1; + self.stream_id = Some(stream_id); + conn.changes = 0; + conn.errcode = 0; + conn.sqlstate = "00000".to_string(); + self.executed = true; + conn.note_transaction_sql(&self.query_string); + return Ok(()); + } let refs: Vec<&(dyn ToSql + Sync)> = params.iter().map(|p| p as &(dyn ToSql + Sync)).collect(); - if self.statement.columns().is_empty() { + if statement.columns().is_empty() { // No result columns: a DML/DDL statement. Run it for the row count. - match conn.client.execute(&self.statement, &refs) { + match conn.client.execute(statement, &refs) { Ok(n) => { conn.changes = n as i64; conn.errcode = 0; + conn.sqlstate = "00000".to_string(); self.executed = true; + conn.note_transaction_sql(&self.query_string); Ok(()) } - Err(e) => { - conn.errmsg = e.to_string(); - conn.errcode = 1; - Err(-1) - } + // `fail` records the SQLSTATE/message/native code and yields the `-1` + // that `step()` propagates as the statement's error return. + Err(e) => Err(conn.fail(e)), } } else { - match conn.client.query(&self.statement, &refs) { + match conn.client.query(statement, &refs) { Ok(rows) => { self.rows = rows.iter().map(|r| decode_row(r)).collect(); - conn.changes = self.rows.len() as i64; + conn.changes = if self.buffered { + self.rows.len() as i64 + } else { + 0 + }; conn.errcode = 0; + conn.sqlstate = "00000".to_string(); self.executed = true; + conn.note_transaction_sql(&self.query_string); Ok(()) } - Err(e) => { - conn.errmsg = e.to_string(); - conn.errcode = 1; - Err(-1) + Err(e) => Err(conn.fail(e)), + } + } + } + + /// Executes an emulated PostgreSQL statement through the simple-query + /// protocol and materializes the final row-producing result set as text. + fn execute_emulated(&mut self, conn: &mut PgConn) -> Result<(), i64> { + if self.bound.iter().any(|bound| !bound) { + conn.errcode = PG_NATIVE_ERRCODE; + conn.sqlstate = "HY093".to_string(); + conn.errmsg = "Invalid parameter number: number of bound variables does not match number of tokens".to_string(); + return Err(-1); + } + let sql = match interpolate_emulated_sql( + self.emulated_sql + .as_deref() + .expect("emulated PostgreSQL statement missing SQL"), + &self.emulated_markers, + &self.binds, + ) { + Ok(sql) => sql, + Err(message) => { + conn.errcode = PG_NATIVE_ERRCODE; + conn.sqlstate = "HY093".to_string(); + conn.errmsg = message; + return Err(-1); + } + }; + self.sent_sql = sql.clone(); + if !self.buffered && self.simple_streaming { + let stream_id = conn.start_simple_stream(sql)?; + self.rows.clear(); + self.cursor = -1; + self.stream_id = Some(stream_id); + conn.changes = 0; + conn.errcode = 0; + conn.sqlstate = "00000".to_string(); + self.executed = true; + conn.note_transaction_sql(&self.query_string); + return Ok(()); + } + match conn.client.simple_query(&sql) { + Ok(messages) => { + self.rows.clear(); + self.col_names.clear(); + let mut changes = 0i64; + for message in messages { + match message { + SimpleQueryMessage::RowDescription(columns) => { + self.rows.clear(); + self.col_names = columns + .iter() + .map(|column| column.name().to_string()) + .collect(); + self.col_tables = vec![String::new(); columns.len()]; + } + SimpleQueryMessage::Row(row) => { + let cells = (0..row.len()) + .map(|index| match row.get(index) { + Some(value) => Cell::Text(value.to_string()), + None => Cell::Null, + }) + .collect(); + self.rows.push(cells); + } + SimpleQueryMessage::CommandComplete(count) => { + changes = count as i64; + } + _ => {} + } } + conn.changes = if self.rows.is_empty() { + changes + } else if !self.buffered { + 0 + } else { + self.rows.len() as i64 + }; + conn.errcode = 0; + conn.sqlstate = "00000".to_string(); + self.executed = true; + conn.note_transaction_sql(&self.query_string); + Ok(()) } + Err(error) => Err(conn.fail(error)), } } /// Advances to the next row: `1` for a row, `0` when exhausted, `-1` on /// error. Executes lazily on the first call. pub fn step(&mut self, conn: &mut PgConn) -> i64 { + if self.executed + && !self.buffered + && self.query_generation != conn.query_generation + { + self.cursor = self.rows.len() as isize; + return 0; + } if !self.executed { if let Err(code) = self.execute(conn) { return code; } } + if let Some(stream_id) = self.stream_id { + return match conn.next_stream_row(stream_id) { + Ok(Some((columns, row))) => { + if !columns.is_empty() { + self.col_names = columns; + self.col_tables = vec![String::new(); self.col_names.len()]; + } + self.rows.clear(); + self.rows.push(row); + self.cursor = 0; + 1 + } + Ok(None) => { + self.rows.clear(); + self.cursor = 0; + self.stream_id = None; + 0 + } + Err(code) => code, + }; + } self.cursor += 1; if (self.cursor as usize) < self.rows.len() { 1 @@ -475,6 +2972,47 @@ impl PgStmt { } } + /// Executes lazily and moves a materialized PostgreSQL result cursor according + /// to PDO's scroll orientations. Absolute positions use PostgreSQL's one-based + /// cursor convention; negative absolute positions count backward from the end. + pub fn step_oriented(&mut self, conn: &mut PgConn, orientation: i64, offset: i64) -> i64 { + if self.executed + && !self.buffered + && self.query_generation != conn.query_generation + { + self.cursor = self.rows.len() as isize; + return 0; + } + if !self.executed { + if let Err(code) = self.execute(conn) { + return code; + } + } + let len = self.rows.len() as i128; + let current = self.cursor as i128; + let target = match orientation { + 0 => current + 1, + 1 => current - 1, + 2 => 0, + 3 => len - 1, + 4 if offset > 0 => i128::from(offset) - 1, + 4 if offset < 0 => len + i128::from(offset), + 4 => -1, + 5 => current + i128::from(offset), + _ => return 0, + }; + if target < 0 { + self.cursor = -1; + return 0; + } + if target >= len { + self.cursor = self.rows.len() as isize; + return 0; + } + self.cursor = target as isize; + 1 + } + /// Returns the current cell at column `i`, if a row is active. fn cell(&self, i: i64) -> Option<&Cell> { if self.cursor < 0 { @@ -487,7 +3025,11 @@ impl PgStmt { /// Number of result columns (available before execution). pub fn column_count(&self) -> i64 { - self.col_names.len() as i64 + if self.emulated_sql.is_some() && !self.executed && self.col_names.is_empty() { + 1 + } else { + self.col_names.len() as i64 + } } /// Name of result column `i` (0-based). @@ -495,6 +3037,14 @@ impl PgStmt { self.col_names.get(i as usize).cloned().unwrap_or_default() } + /// Returns the `pg_class.relname` resolved from the result column's table OID. + pub fn column_table_name(&self, i: i64) -> String { + if i < 0 { + return String::new(); + } + self.col_tables.get(i as usize).cloned().unwrap_or_default() + } + /// SQLite-compatible type code for the current row's column `i`: /// 1=int, 2=float, 3=text, 4=bytea/blob, 5=null. pub fn column_type(&self, i: i64) -> i64 { @@ -507,6 +3057,171 @@ impl PgStmt { } } + /// Returns the bytes currently owned by this statement's materialized result, + /// including row/cell storage and heap-backed text/byte payload capacities. + /// `None` means the statement has not executed yet. + pub fn result_memory_size(&self) -> Option { + if !self.executed { + return None; + } + let visible_rows: &[Vec] = if self.buffered { + &self.rows + } else { + self.rows + .get(self.cursor.max(0) as usize) + .map(std::slice::from_ref) + .unwrap_or(&[]) + }; + let mut bytes = visible_rows.len() * std::mem::size_of::>(); + for row in visible_rows { + bytes = bytes.saturating_add(row.capacity() * std::mem::size_of::()); + for cell in row { + bytes = bytes.saturating_add(match cell { + Cell::Text(value) => value.capacity(), + Cell::Bytes(value) => value.capacity(), + Cell::Null | Cell::Int(_) | Cell::Float(_) => 0, + }); + } + } + bytes = bytes.saturating_add( + self.col_names + .iter() + .map(|name| name.capacity()) + .sum::(), + ); + Some(i64::try_from(bytes).unwrap_or(i64::MAX)) + } + + /// PostgreSQL native type name of result column `i` (0-based) — the server's + /// own `pg_type.typname` (`int4`, `bool`, `bytea`, `varchar`, …) that the + /// driver resolved at prepare time off the retained `Statement`. Because it + /// comes from the column descriptor rather than a live cell, it is available + /// whether or not a row is active and reflects the column's DECLARED type + /// instead of a NULL value's runtime storage class. Empty string for an + /// out-of-range index. Backs `getColumnMeta`'s `native_type` on a `pgsql:` + /// statement (P2-k). + pub fn column_native_type(&self, i: i64) -> String { + if i < 0 { + return String::new(); + } + self.statement + .as_ref() + .and_then(|statement| statement.columns().get(i as usize)) + .map(|c| c.type_().name().to_string()) + .unwrap_or_default() + } + + /// PostgreSQL type OID of result column `i` (0-based) — the `PQftype` value + /// carried by the column's `postgres::types::Type`. Backs `getColumnMeta`'s + /// `pgsql:oid` key and, prelude-side, the PDO param-type derivation + /// (BOOL→PARAM_BOOL, int-family→PARAM_INT, BYTEA→PARAM_LOB, else PARAM_STR). + /// `0` (the invalid OID) for an out-of-range index. (P2-k) + pub fn column_type_oid(&self, i: i64) -> i64 { + if i < 0 { + return 0; + } + self.statement + .as_ref() + .and_then(|statement| statement.columns().get(i as usize)) + .map(|c| i64::from(c.type_().oid())) + .unwrap_or(0) + } + + /// OID of the table result column `i` (0-based) was selected FROM, or `0` + /// (`InvalidOid`) when the column is not a plain table column — an expression, + /// a literal, an aggregate, a function result. Backs `getColumnMeta`'s + /// `pgsql:table_oid` key, which php-src's `pgsql_stmt_get_column_meta` + /// (`ext/pdo_pgsql/pgsql_statement.c`) emits UNCONDITIONALLY from `PQftable()`, + /// including the `0` for an expression column (F-PG-01). + /// + /// Exact `PQftable()` parity, straight off the wire: the RowDescription message + /// carries a per-field table OID, and tokio-postgres keeps it on `Column` + /// (`Column::table_oid()`, statement.rs:104). It normalizes the wire's `0` to + /// `None` (prepare.rs:100, `.filter(|n| *n != 0)`), so mapping `None` back to `0` + /// here restores the server's value byte for byte. No catalog lookup and no + /// per-fetch round trip are needed — contrary to the spec's premise, the pinned + /// crate does surface this. + /// + /// `0` for an out-of-range index, which is also the neutral `InvalidOid`. + pub fn column_table_oid(&self, i: i64) -> i64 { + if i < 0 { + return 0; + } + self.statement + .as_ref() + .and_then(|statement| statement.columns().get(i as usize)) + .and_then(|c| c.table_oid()) + .map(i64::from) + .unwrap_or(0) + } + + /// Byte width of result column `i`'s type (0-based): a positive fixed width + /// (`int4` → 4, `timestamp` → 8, `uuid` → 16), `-1` for a variable-length + /// (varlena) type (`text`, `varchar`, `numeric`, `bytea`, `json`, any array), + /// or `-2` for a NUL-terminated C string (`cstring`, `unknown`). Backs + /// `getColumnMeta`'s `len`, which php-src fills from `col->maxlen`, itself set + /// straight from `PQfsize()` in `pgsql_stmt_describe` + /// (`ext/pdo_pgsql/pgsql_statement.c:496`) (F-PG-02). + /// + /// ⚠ LIMITATION — this is DERIVED, not the value the server sent. `PQfsize()` is + /// the RowDescription field's "data type size", and while postgres-protocol does + /// parse it (`message/backend.rs:820`, exposed as `Field::type_size()`), + /// tokio-postgres THROWS IT AWAY when it builds `Column` (prepare.rs:98-103 copies + /// only name/table_oid/column_id/type_modifier/type) — there is no + /// `Column::type_size()` to read. Reaching the real value would need either a + /// crate fork or a `pg_type` catalog query, and the latter is impossible here + /// anyway: this accessor takes `&self` with no `Client`, so it could only run at + /// prepare time, adding a server round trip to EVERY prepare for a metadata field + /// almost nothing reads. + /// + /// So the width is recomputed from the column's type instead — which is sound, + /// because that is exactly what the server does: `PQfsize()` returns + /// `pg_type.typlen`, a property of the TYPE, not of the column or the row (an + /// `int4` column is 4 bytes wide in every table of every database). See + /// [`type_len`] for the table and for the one case it cannot cover. + /// + /// `-1` for an out-of-range index (PostgreSQL's own "not a fixed width" value). + pub fn column_len(&self, i: i64) -> i64 { + if i < 0 { + return -1; + } + self.statement + .as_ref() + .and_then(|statement| statement.columns().get(i as usize)) + .map(|c| type_len(c.type_())) + .unwrap_or(-1) + } + + /// Type modifier (`atttypmod`) of result column `i` (0-based), or `-1` when the + /// type takes no modifier or the column carries none. Backs `getColumnMeta`'s + /// `precision`, which php-src fills from `col->precision`, itself set straight + /// from `PQfmod()` in `pgsql_stmt_describe` + /// (`ext/pdo_pgsql/pgsql_statement.c:497`) (F-PG-02). + /// + /// Exact `PQfmod()` parity: the RowDescription carries the type modifier per + /// field and tokio-postgres keeps it verbatim on `Column` + /// (`Column::type_modifier()`, statement.rs:114) — no catalog lookup needed. + /// + /// The value is the RAW `atttypmod`, deliberately NOT decoded into a + /// human-readable precision, because php-src does not decode it either — it + /// copies `PQfmod()` through unchanged, so `VARCHAR(20)` reports 24 (the length + /// plus `VARHDRSZ` = 4) and `NUMERIC(10,2)` reports 655366 (`((10 << 16) | 2) + + /// 4`). Decoding it here would be a divergence from PHP dressed up as an + /// improvement; a caller who wants the real precision must decode the modifier + /// exactly as it would have to against real PDO. + /// + /// `-1` for an out-of-range index (PostgreSQL's own "no type modifier" value). + pub fn column_precision(&self, i: i64) -> i64 { + if i < 0 { + return -1; + } + self.statement + .as_ref() + .and_then(|statement| statement.columns().get(i as usize)) + .map(|c| i64::from(c.type_modifier())) + .unwrap_or(-1) + } + /// Current row's column `i` as an integer. pub fn column_int(&self, i: i64) -> i64 { match self.cell(i) { @@ -529,17 +3244,6 @@ impl PgStmt { } } - /// Current row's column `i` as text. - pub fn column_text(&self, i: i64) -> String { - match self.cell(i) { - Some(Cell::Text(s)) => s.clone(), - Some(Cell::Bytes(b)) => String::from_utf8_lossy(b).into_owned(), - Some(Cell::Int(v)) => v.to_string(), - Some(Cell::Float(v)) => v.to_string(), - _ => String::new(), - } - } - /// Current row's column `i` as byte-counted PDO data. pub fn column_data(&self, i: i64) -> Vec { match self.cell(i) { @@ -552,6 +3256,105 @@ impl PgStmt { } } +/// PostgreSQL's `pg_type.typlen` for `ty` — the byte width the server reports for a +/// column of this type in the RowDescription's "data type size" field, i.e. exactly +/// what `PQfsize()` hands back to php-src. Positive = a fixed width; `-1` = a +/// variable-length (varlena) type; `-2` = a NUL-terminated C string. +/// +/// Recomputed from the type rather than read off the wire because tokio-postgres +/// discards the wire value (see [`PgStmt::column_len`]). That substitution is exact +/// for everything below: `typlen` is a column of `pg_type`, so it is a property of +/// the TYPE alone — the server looks it up by the very type OID the crate hands us. +/// The constants are transcribed from PostgreSQL's own catalog seed data, +/// `src/include/catalog/pg_type.dat`, not inferred. +/// +/// Only the FIXED-width types are enumerated: `-1` is the fallback, and it is the +/// right answer for every varlena type (`text`, `varchar`, `bpchar`, `numeric`, +/// `bytea`, `json`/`jsonb`, `xml`, `bit`/`varbit`, `path`, `polygon`, `tsvector`, +/// `record`, and every array/range/multirange/composite type, all of which are +/// varlena by construction in PostgreSQL). +/// +/// ⚠ The one case this cannot cover: a user-defined or extension type whose kind is +/// `Simple` and whose OID is therefore not one of the constants below reports `-1`. +/// That is correct for the varlena types extensions overwhelmingly define (`hstore`, +/// `citext`, `ltree`, PostGIS `geometry`, …) but WRONG for a fixed-width one, which +/// would report `-1` instead of its true width. `aclitem` is deliberately left out +/// for the same reason from the other direction: its width is not stable across +/// server versions (12 bytes until PostgreSQL 15, 16 from PostgreSQL 16, which +/// widened `AclMode` to 64 bits), so hardcoding either value would be a lie for half +/// the servers — it falls back to `-1`. `name` assumes the default `NAMEDATALEN` of +/// 64, which a server can be recompiled to change. +fn type_len(ty: &Type) -> i64 { + // Two kinds have a width fixed by construction rather than by a catalog constant, + // and their OIDs are assigned per-database so they can never match a constant + // below. An enum is always stored as an OID (`DefineEnum` creates the type with + // `sizeof(Oid)`), and a domain inherits its base type's width verbatim + // (`DefineDomain` copies `typlen` from the base), so recursing yields the truth. + // Arrays, ranges, multiranges and composites are always varlena — they need no arm, + // the `-1` fallback already covers them. `Kind` is `#[non_exhaustive]`. + match ty.kind() { + Kind::Enum(_) => return 4, + Kind::Domain(base) => return type_len(base), + _ => {} + } + match *ty { + // `bool` and `"char"` are single bytes. + Type::BOOL | Type::CHAR => 1, + Type::INT2 => 2, + // The 4-byte types: the 32-bit numerics, `date` (a day count), and the whole + // `reg*` family, every member of which is an OID under the hood. + Type::INT4 + | Type::FLOAT4 + | Type::OID + | Type::XID + | Type::CID + | Type::DATE + | Type::REGPROC + | Type::REGPROCEDURE + | Type::REGOPER + | Type::REGOPERATOR + | Type::REGCLASS + | Type::REGTYPE + | Type::REGCONFIG + | Type::REGDICTIONARY + | Type::REGNAMESPACE + | Type::REGROLE + | Type::REGCOLLATION + | Type::VOID => 4, + // `tid` is a block number (4) plus an offset (2); `macaddr` is 6 raw bytes. + Type::TID | Type::MACADDR => 6, + // The 8-byte types: 64-bit numerics, `money` (an int64 of cents), and the + // date/time types PostgreSQL stores as a 64-bit microsecond count. + Type::INT8 + | Type::FLOAT8 + | Type::MONEY + | Type::TIME + | Type::TIMESTAMP + | Type::TIMESTAMPTZ + | Type::MACADDR8 + | Type::PG_LSN + | Type::XID8 => 8, + // `timetz` is a `time` (8) plus its UTC offset in seconds (4). + Type::TIMETZ => 12, + // `interval` is microseconds (8) + days (4) + months (4); `point` is two + // float8 coordinates. + Type::INTERVAL | Type::UUID | Type::POINT => 16, + // `line` is the three float8 coefficients of `Ax + By + C = 0`; `circle` is a + // centre `point` (16) plus a float8 radius. + Type::LINE | Type::CIRCLE => 24, + // Both are two `point`s: a segment's endpoints, a box's opposite corners. + Type::LSEG | Type::BOX => 32, + // `NAMEDATALEN`, the identifier type's fixed width. + Type::NAME => 64, + // The NUL-terminated C-string types. `unknown` is what an unresolved literal + // types as, so it can genuinely surface as a result column. + Type::CSTRING | Type::UNKNOWN => -2, + // Every remaining type is variable-length. See the doc comment for the one + // case this fallback gets wrong (a fixed-width user-defined type). + _ => -1, + } +} + /// Decodes a result row's columns into PHP-friendly `Cell` scalars, mapping each /// PostgreSQL type to int/float/text and NULLs to `Cell::Null`. Types without a /// direct scalar decoding (e.g. arrays) fall back to a text attempt, then null. @@ -643,3 +3446,267 @@ fn decode_row(row: &Row) -> Vec { }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + /// Emulated interpolation replaces only scanner-generated markers, quotes + /// text and bytes, and preserves a source `$1` token byte-for-byte. + #[test] + fn emulated_interpolation_uses_scanner_marker_ranges() { + let (sql, _, mixed, markers) = + translate_placeholders_with_markers("SELECT '$1', $1, :first, :name"); + assert!(!mixed); + let rendered = interpolate_emulated_sql( + &sql, + &markers, + &[Bind::Text("O'Reilly".to_string()), Bind::Bytes(vec![0, 255])], + ) + .expect("emulated SQL renders"); + assert_eq!( + rendered, + "SELECT '$1', $1, 'O''Reilly', decode('00ff', 'hex')" + ); + } + + /// The TLS keys are consumed by `parse_tls`, not forwarded into the libpq + /// connection string — tokio-postgres's parser rejects `sslrootcert` and the + /// `verify-*` sslmode values, so leaking any of them would break `.parse()`. + #[test] + fn parse_dsn_strips_tls_keys() { + let dsn = "pgsql:host=db.example.com;sslmode=require;sslrootcert=/etc/ca.pem;dbname=app"; + let conn_str = parse_dsn(dsn).expect("dsn parses"); + assert!(conn_str.contains("host='db.example.com'")); + assert!(conn_str.contains("dbname='app'")); + assert!( + !conn_str.contains("sslmode"), + "sslmode must not reach the libpq conn string: {conn_str}" + ); + assert!( + !conn_str.contains("sslrootcert"), + "sslrootcert must not reach the libpq conn string: {conn_str}" + ); + } + + /// A supported translated key is stripped from the native Config string, + /// while an unsupported libpq key fails explicitly instead of disappearing. + #[test] + fn parse_dsn_translates_client_encoding_and_rejects_unsupported_keys() { + let dsn = "pgsql:host=db.example.com;dbname=app;client_encoding=UTF8"; + let conn_str = parse_dsn(dsn).expect("translated DSN parses"); + assert!(conn_str.contains("host='db.example.com'")); + assert!(conn_str.contains("dbname='app'")); + assert!( + !conn_str.contains("client_encoding"), + "translated client_encoding must not reach Config: {conn_str}" + ); + conn_str + .parse::() + .expect("conn string with translated key must parse"); + assert_eq!( + client_encoding_from_dsn(dsn).expect("encoding parses"), + Some("UTF8".to_string()) + ); + let error = parse_dsn("pgsql:host=db.example.com;replication=database") + .expect_err("unsupported replication semantics must fail"); + assert!(error.contains("unsupported PostgreSQL DSN option 'replication'")); + } + + /// Malformed options and unsafe client-encoding values fail during DSN parsing. + #[test] + fn parse_dsn_rejects_malformed_and_invalid_client_encoding() { + assert!(parse_dsn("pgsql:host=localhost;broken").is_err()); + assert!(parse_dsn("pgsql:host=localhost;client_encoding=UTF8' RESET ALL") + .is_err()); + } + + /// F-CORE-02: the prelude percent-encodes a constructor-supplied password + /// containing ';' (here `a;b` -> `a%3Bb`) before folding it into the DSN, so + /// it survives `body.split(';')` intact instead of truncating at the + /// embedded ';'. `parse_dsn` must undo that encoding before the value + /// reaches the libpq conninfo string, landing on the original `a;b` (quoted, + /// with no `%3B` left in it). + #[test] + fn parse_dsn_percent_decodes_a_password_containing_semicolon() { + let dsn = "pgsql:host=db.example.com;user=admin;password=a%3Bb"; + let conn_str = parse_dsn(dsn).expect("dsn parses"); + assert!( + conn_str.contains("password='a;b'"), + "expected the decoded password in: {conn_str}" + ); + assert!( + !conn_str.contains("%3B"), + "the percent-escape must not reach the libpq conn string: {conn_str}" + ); + // The whole point: tokio-postgres's own parser must accept the decoded value. + conn_str + .parse::() + .expect("conn string with a decoded password must still parse"); + } + + /// `parse_tls` captures `sslmode` (lowercased) and the three file paths. + #[test] + fn parse_tls_captures_mode_and_paths() { + let tls = parse_tls( + "pgsql:host=h;sslmode=VERIFY-FULL;sslrootcert=/ca.pem;sslcert=/c.pem;sslkey=/k.pem;sslcrl=/root.crl;sslcrldir=/crls", + ) + .expect("TLS options parse"); + assert_eq!(tls.mode, "verify-full"); + assert_eq!(tls.root_cert.as_deref(), Some("/ca.pem")); + assert_eq!(tls.client_cert.as_deref(), Some("/c.pem")); + assert_eq!(tls.client_key.as_deref(), Some("/k.pem")); + assert_eq!(tls.crl_file.as_deref(), Some("/root.crl")); + assert_eq!(tls.crl_directory.as_deref(), Some("/crls")); + assert!(tls.server_name_indication); + } + + /// A DSN without TLS keys yields the unset defaults (libpq/tokio-postgres both + /// default to `prefer`, represented here by an empty mode). + #[test] + fn parse_tls_defaults_when_absent() { + let tls = parse_tls("pgsql:host=h;dbname=d").expect("TLS defaults parse"); + assert!(tls.mode.is_empty()); + assert!(tls.root_cert.is_none()); + assert!(tls.server_name_indication); + } + + /// A named libpq service contributes defaults while explicit DSN values win, + /// including legacy `fallback_application_name` normalization. + #[test] + fn parse_dsn_resolves_service_file_with_explicit_precedence() { + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); + let id = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "elephc-pdo-pg-service-test-{}-{id}", + std::process::id() + )); + fs::create_dir(&dir).expect("create service fixture directory"); + let service_file = dir.join("pg_service.conf"); + fs::write( + &service_file, + "[other]\nhost=ignored\n[app]\nhost=service-host\nport=5433\ndbname=service-db\nfallback_application_name=elephc\n", + ) + .expect("write service fixture"); + + let dsn = format!( + "pgsql:service=app;servicefile={};host=explicit-host;user=alice", + service_file.display() + ); + let conn_str = parse_dsn(&dsn).expect("service DSN resolves"); + assert!(conn_str.contains("host='explicit-host'")); + assert!(conn_str.contains("port='5433'")); + assert!(conn_str.contains("dbname='service-db'")); + assert!(conn_str.contains("application_name='elephc'")); + assert!(!conn_str.contains("service=")); + + fs::remove_dir_all(dir).expect("remove service fixture directory"); + } + + /// A secure `.pgpass` supplies the first wildcard-matching password and + /// correctly unescapes colons and backslashes in its password field. + #[test] + fn parse_dsn_resolves_password_file() { + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); + let id = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "elephc-pdo-pg-passfile-test-{}-{id}", + std::process::id() + )); + fs::create_dir(&dir).expect("create passfile fixture directory"); + let passfile = dir.join(".pgpass"); + fs::write( + &passfile, + "wrong:5432:app:alice:nope\nlocalhost:5432:app:alice:s3cr\\:et\\\\tail\n", + ) + .expect("write passfile fixture"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&passfile, fs::Permissions::from_mode(0o600)) + .expect("secure passfile permissions"); + } + + let dsn = format!( + "pgsql:host=localhost;port=5432;dbname=app;user=alice;passfile={}", + passfile.display() + ); + let conn_str = parse_dsn(&dsn).expect("passfile DSN resolves"); + assert!(conn_str.contains("password='s3cr:et\\\\tail'")); + assert!(!conn_str.contains("passfile")); + + fs::remove_dir_all(dir).expect("remove passfile fixture directory"); + } + + /// Legacy libpq TLS aliases and modern protocol/SNI controls are translated + /// into the rustls configuration without leaking into `postgres::Config`. + #[test] + fn parse_tls_honors_legacy_alias_sni_and_protocol_bounds() { + let tls = parse_tls( + "pgsql:host=h;requiressl=1;sslcompression=0;sslcertmode=disable;sslsni=0;ssl_min_protocol_version=TLSv1.2;ssl_max_protocol_version=TLSv1.3", + ) + .expect("extended TLS options parse"); + assert_eq!(tls.mode, "require"); + assert!(!tls.server_name_indication); + assert_eq!(tls.client_cert_mode, "disable"); + assert_eq!(tls.min_protocol_version.as_deref(), Some("TLSv1.2")); + assert_eq!(tls.max_protocol_version.as_deref(), Some("TLSv1.3")); + assert!(parse_dsn( + "pgsql:host=h;ssl_min_protocol_version=TLSv1.3;ssl_max_protocol_version=TLSv1.2" + ) + .is_ok()); + assert!(parse_tls( + "pgsql:host=h;ssl_min_protocol_version=TLSv1.3;ssl_max_protocol_version=TLSv1.2" + ) + .is_err()); + assert!(parse_tls("pgsql:host=h;sslcertmode=require").is_err()); + } + + /// Building the rustls connector with the bundled webpki roots exercises the + /// explicit ring `CryptoProvider` and the whole `ClientConfig` builder chain, + /// catching a provider/API break without needing a live TLS server. + #[cfg(feature = "tls")] + #[test] + fn build_tls_connector_with_webpki_roots_succeeds() { + let tls = PgTls { + mode: "require".to_string(), + ..PgTls::default() + }; + assert!(build_tls_connector(&tls).is_ok()); + } + + /// A missing custom `sslrootcert` file is a clear, labelled error — not a panic. + #[cfg(feature = "tls")] + #[test] + fn build_tls_connector_missing_ca_errors() { + let tls = PgTls { + mode: "verify-full".to_string(), + root_cert: Some("/nonexistent/elephc-does-not-exist-ca.pem".to_string()), + ..PgTls::default() + }; + // `MakeRustlsConnect` has no `Debug`, so match rather than `unwrap_err`. + match build_tls_connector(&tls) { + Ok(_) => panic!("expected an error for a missing sslrootcert file"), + Err(err) => assert!(err.contains("sslrootcert"), "unexpected error: {err}"), + } + } + + /// An explicitly configured missing CRL path fails during connector creation. + #[cfg(feature = "tls")] + #[test] + fn build_tls_connector_missing_crl_errors() { + let tls = PgTls { + mode: "verify-full".to_string(), + crl_file: Some("/nonexistent/elephc-does-not-exist.crl".to_string()), + ..PgTls::default() + }; + match build_tls_connector(&tls) { + Ok(_) => panic!("expected an error for a missing sslcrl file"), + Err(error) => assert!(error.contains("CRL"), "unexpected error: {error}"), + } + } +} diff --git a/crates/elephc-pdo/src/pg_libpq.rs b/crates/elephc-pdo/src/pg_libpq.rs new file mode 100644 index 0000000000..7447153c37 --- /dev/null +++ b/crates/elephc-pdo/src/pg_libpq.rs @@ -0,0 +1,982 @@ +//! Purpose: +//! PostgreSQL PDO backend using the same libpq client library as php-src. This +//! build-time-selected backend supplies GSSAPI and libpq-only connection options. +//! +//! Called from: +//! - `crate::lib` when `elephc-pdo` is built with `libpq-gss`. +//! +//! Key details: +//! - Explicit PDO DSN pairs are handed to `PQconnectdb`; libpq itself resolves +//! services, passfiles, environment defaults, GSS/Kerberos authentication, +//! encrypted keys, authentication policy, and replication startup parameters. +//! - Unbuffered execution uses `PQsend*` plus `PQsetSingleRowMode`, matching PHP +//! 8.5+ rather than emulating GSS on a second pure-Rust connection. + +use std::collections::{HashMap, VecDeque}; +use std::ffi::{c_char, c_void, CStr}; +use std::sync::Mutex; +use std::thread; +use std::time::{Duration, Instant}; + +use libpq::result::ErrorField; +use libpq::{Connection, Format, PQResult, Status}; + +pub use crate::pg_native::Bind; +#[cfg(test)] +pub use crate::pg_native::parse_dsn; +#[cfg(test)] +pub use crate::pg_native::translate_placeholders; +pub(crate) use crate::pg_native::transaction_state_after_sql; +use crate::pg_native::{ + explicit_dsn_options, interpolate_emulated_sql, translate_placeholders_with_markers, +}; + +/// One decoded result value in the common bridge representation. +pub enum Cell { + Null, + Int(i64), + Float(f64), + Text(String), + Bytes(Vec), +} + +/// Metadata copied from a libpq result descriptor. +#[derive(Clone, Default)] +struct ColumnMeta { + name: String, + native_type: String, + type_oid: i64, + table_oid: i64, + table_name: String, + len: i64, + precision: i64, +} + +/// A live libpq connection with PDO-visible error and transaction bookkeeping. +pub struct PgConn { + client: Connection, + pub changes: i64, + pub errmsg: String, + pub errcode: i64, + pub sqlstate: String, + pub prefetch: bool, + pub in_transaction: bool, + generation: u64, + statement_counter: u64, + notices: Box>>, +} + +/// A libpq-backed PDO statement, buffered or in single-row mode. +pub struct PgStmt { + pub conn_id: i64, + pub query_string: String, + translated_sql: String, + emulated: bool, + markers: Vec<(usize, usize, usize)>, + statement_name: Option, + pub sent_sql: String, + pub named_map: HashMap, + pub binds: Vec, + bound: Vec, + columns: Vec, + pub rows: Vec>, + pub cursor: isize, + pub executed: bool, + pub buffered: bool, + simple_streaming: bool, + streaming: bool, + generation: u64, +} + +/// Returns true for libpq result statuses representing successful commands. +fn result_ok(result: &PQResult) -> bool { + matches!( + result.status(), + Status::CommandOk | Status::TuplesOk | Status::SingleTuple | Status::EmptyQuery + ) +} + +/// Extracts SQLSTATE from a libpq result, falling back for client-side failures. +fn result_sqlstate(result: &PQResult) -> String { + result + .error_field(ErrorField::Sqlstate) + .ok() + .flatten() + .unwrap_or("HY000") + .to_string() +} + +/// Converts a libpq result error into PDO connection error state. +fn record_result_error(conn: &mut PgConn, result: &PQResult) -> i64 { + conn.sqlstate = result_sqlstate(result); + conn.errmsg = result + .error_message() + .ok() + .flatten() + .unwrap_or_else(|| "PostgreSQL libpq operation failed".to_string()); + conn.errcode = result.status() as i64; + -1 +} + +/// Formats a libpq integer server version as PostgreSQL's dotted version text. +fn format_server_version(version: i32) -> String { + let major = version / 10_000; + let minor = (version / 100) % 100; + let patch = version % 100; + if major >= 10 { + format!("{major}.{patch}") + } else { + format!("{major}.{minor}.{patch}") + } +} + +/// Maps well-known PostgreSQL OIDs to php-src native type names. +fn native_type_name(oid: i64) -> String { + match oid { + 16 => "bool", + 17 => "bytea", + 20 => "int8", + 21 => "int2", + 23 => "int4", + 25 => "text", + 26 => "oid", + 700 => "float4", + 701 => "float8", + 1042 => "bpchar", + 1043 => "varchar", + 1082 => "date", + 1083 => "time", + 1114 => "timestamp", + 1184 => "timestamptz", + 1700 => "numeric", + 2950 => "uuid", + 114 => "json", + 3802 => "jsonb", + _ => "", + } + .to_string() +} + +/// Decodes PostgreSQL's text-format bytea representation. +fn decode_bytea(value: &[u8]) -> Vec { + let Some(hex) = value.strip_prefix(b"\\x") else { + return value.to_vec(); + }; + hex.chunks_exact(2) + .filter_map(|pair| std::str::from_utf8(pair).ok()) + .filter_map(|pair| u8::from_str_radix(pair, 16).ok()) + .collect() +} + +/// Decodes one libpq result row according to each field OID. +fn decode_result_row(result: &PQResult, row: usize) -> Vec { + (0..result.nfields()) + .map(|column| { + let Some(value) = result.value(row, column) else { + return Cell::Null; + }; + match result.field_type(column) as i64 { + 16 => Cell::Int(matches!(value, b"t" | b"true" | b"1") as i64), + 20 | 21 | 23 | 26 => std::str::from_utf8(value) + .ok() + .and_then(|value| value.parse().ok()) + .map(Cell::Int) + .unwrap_or_else(|| Cell::Text(String::from_utf8_lossy(value).into_owned())), + 700 | 701 => std::str::from_utf8(value) + .ok() + .and_then(|value| value.parse().ok()) + .map(Cell::Float) + .unwrap_or_else(|| Cell::Text(String::from_utf8_lossy(value).into_owned())), + // PostgreSQL numeric is text in PDO so its declared scale and + // arbitrary precision survive the bridge unchanged. + 1700 => Cell::Text(String::from_utf8_lossy(value).into_owned()), + 17 => Cell::Bytes(decode_bytea(value)), + _ => Cell::Text(String::from_utf8_lossy(value).into_owned()), + } + }) + .collect() +} + +/// Copies result column descriptors and resolves source table names through libpq. +fn result_columns( + client: &Connection, + result: &PQResult, + resolve_table_names: bool, +) -> Vec { + (0..result.nfields()) + .map(|column| { + let table_oid = result.field_table(column).unwrap_or(0) as i64; + let table_name = if table_oid == 0 || !resolve_table_names { + String::new() + } else { + let lookup = client.exec(&format!( + "SELECT relname FROM pg_catalog.pg_class WHERE oid = {table_oid}" + )); + lookup + .value(0, 0) + .map(|value| String::from_utf8_lossy(value).into_owned()) + .unwrap_or_default() + }; + let type_oid = result.field_type(column) as i64; + ColumnMeta { + name: result.field_name(column).ok().flatten().unwrap_or_default(), + native_type: native_type_name(type_oid), + type_oid, + table_oid, + table_name, + len: result.field_size(column).map(|size| size as i64).unwrap_or(-1), + precision: result.field_mod(column).map(i64::from).unwrap_or(-1), + } + }) + .collect() +} + +/// Converts one bound value into a libpq parameter buffer and format. +fn bind_bytes(bind: &Bind) -> (Option>, Format) { + match bind { + Bind::Null => (None, Format::Text), + Bind::Int(value) => ( + Some(nul_terminated_text(value.to_string().into_bytes())), + Format::Text, + ), + Bind::Float(value) => ( + Some(nul_terminated_text(value.to_string().into_bytes())), + Format::Text, + ), + Bind::Text(value) => ( + Some(nul_terminated_text(value.as_bytes().to_vec())), + Format::Text, + ), + Bind::Bytes(value) => (Some(value.clone()), Format::Binary), + } +} + +/// Appends the C terminator required by libpq for text-format parameter values. +fn nul_terminated_text(mut value: Vec) -> Vec { + value.push(0); + value +} + +/// Renders explicit PDO DSN pairs as safely quoted libpq conninfo while leaving +/// service, passfile, and environment resolution to `PQconnectdb`. +fn libpq_conninfo(dsn: &str) -> Result { + let mut options = explicit_dsn_options(dsn)?; + // php-src always supplies a bounded default when neither the DSN nor + // PDO::ATTR_TIMEOUT selected one. Keep both PostgreSQL backends identical. + options + .entry("connect_timeout".to_string()) + .or_insert_with(|| "30".to_string()); + Ok(options + .iter() + .map(|(key, value)| { + format!( + "{}='{}'", + key, + value.replace('\\', "\\\\").replace('\'', "\\'") + ) + }) + .collect::>() + .join(" ")) +} + +/// Buffers a libpq notice without re-entering compiled PHP from libpq's callback. +unsafe extern "C" fn notice_processor(argument: *mut c_void, message: *const c_char) { + if argument.is_null() || message.is_null() { + return; + } + let queue = unsafe { &*(argument as *const Mutex>) }; + let message = unsafe { CStr::from_ptr(message) } + .to_string_lossy() + .trim() + .to_string(); + if let Ok(mut queue) = queue.lock() { + queue.push_back(message); + } +} + +impl PgConn { + /// Opens the DSN through `PQconnectdb`, leaving every libpq-specific keyword intact. + pub fn open(dsn: &str) -> Result { + let conninfo = libpq_conninfo(dsn)?; + let client = Connection::new(&conninfo).map_err(|error| error.to_string())?; + let notices = Box::new(Mutex::new(VecDeque::new())); + let notice_pointer = (&*notices) as *const Mutex> as *mut c_void; + unsafe { + client.set_notice_processor(Some(notice_processor), notice_pointer); + } + Ok(Self { + client, + changes: 0, + errmsg: String::new(), + errcode: 0, + sqlstate: "00000".to_string(), + prefetch: true, + in_transaction: false, + generation: 0, + statement_counter: 0, + notices, + }) + } + + /// Sets the default prefetch mode for future statements. + pub fn set_prefetch(&mut self, prefetch: bool) -> i64 { + self.prefetch = prefetch; + 1 + } + + /// Starts a new query generation after draining any prior async results. + fn begin_query(&mut self) -> u64 { + while self.client.result().is_some() {} + self.generation = self.generation.wrapping_add(1).max(1); + self.generation + } + + /// Records successful execution and transaction state. + fn succeed(&mut self, sql: &str, changes: i64) { + self.changes = changes; + self.errcode = 0; + self.errmsg.clear(); + self.sqlstate = "00000".to_string(); + self.in_transaction = transaction_state_after_sql(sql, self.in_transaction); + } + + /// Drains one notice buffered by libpq's notice processor. + pub fn drain_notice(&self) -> String { + self.notices + .lock() + .ok() + .and_then(|mut notices| notices.pop_front()) + .unwrap_or_default() + } + + /// Resets a persistent PostgreSQL session for PHP 8.6 semantics. + pub fn discard_all(&mut self) { + let _ = self.exec_simple("DISCARD ALL"); + } + + /// Executes SQL and returns affected rows or `-1`. + pub fn exec(&mut self, sql: &str) -> i64 { + self.begin_query(); + let result = self.client.exec(sql); + if !result_ok(&result) { + return record_result_error(self, &result); + } + let changes = result.cmd_tuples().unwrap_or(0) as i64; + self.succeed(sql, changes); + changes + } + + /// Executes one transaction-control command through libpq, returning `1`/`0`. + pub fn exec_simple(&mut self, sql: &str) -> i64 { + (self.exec(sql) >= 0) as i64 + } + + /// Returns the current or named PostgreSQL sequence value as an integer. + pub fn last_insert_id(&mut self, name: Option<&str>) -> i64 { + self.last_insert_id_text(name).parse().unwrap_or(0) + } + + /// Returns the current or named PostgreSQL sequence value without truncation. + pub fn last_insert_id_text(&mut self, name: Option<&str>) -> String { + self.begin_query(); + let query = name + .filter(|name| !name.is_empty()) + .map(|name| format!("SELECT currval('{}')", name.replace('\'', "''"))) + .unwrap_or_else(|| "SELECT lastval()".to_string()); + let result = self.client.exec(&format!("{query}::text")); + if !result_ok(&result) { + record_result_error(self, &result); + return String::new(); + } + self.errcode = 0; + self.errmsg.clear(); + self.sqlstate = "00000".to_string(); + result.value(0, 0).map(|value| String::from_utf8_lossy(value).into_owned()).unwrap_or_default() + } + + /// Returns the connected PostgreSQL server version. + pub fn server_version(&mut self) -> String { + format_server_version(self.client.server_version()) + } + + /// Returns the linked libpq client version. + pub fn client_version(&self) -> String { + format!("postgres libpq {}", format_server_version(libpq::version())) + } + + /// Returns a libpq-style connection status string. + pub fn connection_status(&self) -> String { + if self.is_closed() { + "Bad connection.".to_string() + } else { + "Connection OK; waiting to send.".to_string() + } + } + + /// Reports whether libpq considers the connection unusable. + pub fn is_closed(&self) -> bool { + self.client.status() != libpq::connection::Status::Ok + } + + /// Returns PDO's PostgreSQL server-information summary. + pub fn server_info(&mut self) -> String { + format!( + "PID: {}; Client Encoding: {}", + self.backend_pid(), + self.client.parameter_status("client_encoding").unwrap_or_default() + ) + } + + /// Returns the backend process identifier. + pub fn backend_pid(&mut self) -> i64 { + self.client.backend_pid() as i64 + } + + /// Creates a PostgreSQL large object and returns its OID. + pub fn lob_create(&mut self) -> String { + self.scalar_text("SELECT lo_create(0)") + } + + /// Deletes a PostgreSQL large object. + pub fn lob_unlink(&mut self, oid: &str) -> i64 { + self.scalar_text(&format!("SELECT lo_unlink({})", oid.parse::().unwrap_or(0))) + .parse() + .unwrap_or(0) + } + + /// Reads a complete PostgreSQL large object for the legacy ABI. + pub fn lob_get(&mut self, oid: &str) -> Option> { + let result = self.client.exec(&format!( + "SELECT encode(lo_get({}), 'hex')", + oid.parse::().ok()? + )); + result.value(0, 0).map(decode_hex) + } + + /// Replaces a PostgreSQL large object from offset zero. + pub fn lob_put(&mut self, oid: &str, data: &[u8]) -> i64 { + let hex = hex_bytes(data); + self.exec_simple(&format!( + "SELECT lo_put({}, 0, decode('{hex}', 'hex'))", + oid.parse::().unwrap_or(0) + )); + (self.errcode == 0) as i64 + } + + /// Returns a PostgreSQL large object's byte size. + pub fn lob_size(&mut self, oid: &str) -> Option { + self.scalar_text(&format!( + "SELECT octet_length(lo_get({}))", + oid.parse::().ok()? + )) + .parse() + .ok() + } + + /// Reads one bounded PostgreSQL large-object slice. + pub fn lob_read_at(&mut self, oid: &str, offset: i64, length: i64) -> Option> { + if offset < 0 || length < 0 { + return None; + } + let result = self.client.exec(&format!( + "SELECT encode(lo_get({}, {offset}, {length}), 'hex')", + oid.parse::().ok()? + )); + result.value(0, 0).map(decode_hex) + } + + /// Writes one bounded PostgreSQL large-object slice. + pub fn lob_write_at(&mut self, oid: &str, offset: i64, data: &[u8]) -> i64 { + if offset < 0 { + return -1; + } + let hex = hex_bytes(data); + let result = self.client.exec(&format!( + "SELECT lo_put({}, {offset}, decode('{hex}', 'hex'))", + oid.parse::().unwrap_or(0) + )); + if result_ok(&result) { + data.len() as i64 + } else { + record_result_error(self, &result) + } + } + + /// Streams bytes into COPY FROM STDIN through libpq. + pub fn copy_in(&mut self, copy_sql: &str, data: &[u8]) -> i64 { + let result = self.client.exec(copy_sql); + if result.status() != Status::CopyIn { + return record_result_error(self, &result); + } + if self.client.put_copy_data(data).is_err() || self.client.put_copy_end(None).is_err() { + return -1; + } + let final_result = self.client.result(); + final_result + .as_ref() + .and_then(|result| result.cmd_tuples().ok()) + .unwrap_or(0) as i64 + } + + /// Collects COPY TO STDOUT bytes through libpq. + pub fn copy_out(&mut self, copy_sql: &str) -> String { + let result = self.client.exec(copy_sql); + if result.status() != Status::CopyOut { + record_result_error(self, &result); + return String::new(); + } + let mut output = Vec::new(); + while let Ok(chunk) = self.client.copy_data(false) { + output.extend_from_slice(&chunk); + } + while self.client.result().is_some() {} + String::from_utf8_lossy(&output).into_owned() + } + + /// Polls libpq for one LISTEN/NOTIFY message until the requested deadline. + pub fn get_notify(&mut self, timeout_ms: i64) -> String { + let deadline = Instant::now() + Duration::from_millis(timeout_ms.max(0) as u64); + loop { + let _ = self.client.consume_input(); + if let Some(notification) = self.client.notifies() { + return format!( + "{}\t{}\t{}", + notification.relname().unwrap_or_default(), + notification.be_pid(), + notification.extra().unwrap_or_default() + ); + } + if Instant::now() >= deadline { + return String::new(); + } + thread::sleep(Duration::from_millis(5)); + } + } + + /// Prepares a PDO statement and copies libpq's result descriptor. + pub fn prepare(&mut self, sql: &str, emulated: bool) -> Result { + self.begin_query(); + let (translated, named_map, mixed, markers) = translate_placeholders_with_markers(sql); + if mixed { + self.sqlstate = "HY093".to_string(); + return Err("Invalid parameter number: mixed named and positional parameters".to_string()); + } + let param_count = markers.iter().map(|marker| marker.2).max().unwrap_or(0); + let mut statement_name = None; + let mut columns = Vec::new(); + if !emulated { + self.statement_counter = self.statement_counter.wrapping_add(1); + let name = format!("elephc_pdo_{}", self.statement_counter); + let prepared = self.client.prepare(Some(&name), &translated, &[]); + if !result_ok(&prepared) { + record_result_error(self, &prepared); + return Err(self.errmsg.clone()); + } + let description = self.client.describe_prepared(Some(&name)); + columns = result_columns(&self.client, &description, true); + statement_name = Some(name); + } + self.errcode = 0; + self.errmsg.clear(); + self.sqlstate = "00000".to_string(); + Ok(PgStmt { + conn_id: 0, + query_string: sql.to_string(), + translated_sql: translated, + emulated, + markers, + statement_name, + sent_sql: String::new(), + named_map, + binds: vec![Bind::Null; param_count], + bound: vec![false; param_count], + columns, + rows: Vec::new(), + cursor: -1, + executed: false, + buffered: self.prefetch, + simple_streaming: false, + streaming: false, + generation: 0, + }) + } + + /// Executes a scalar query and returns its first textual field. + fn scalar_text(&mut self, sql: &str) -> String { + self.begin_query(); + let result = self.client.exec(sql); + if !result_ok(&result) { + record_result_error(self, &result); + return String::new(); + } + self.errcode = 0; + self.errmsg.clear(); + self.sqlstate = "00000".to_string(); + result + .value(0, 0) + .map(|value| String::from_utf8_lossy(value).into_owned()) + .unwrap_or_default() + } +} + +impl PgStmt { + /// Enables PHP 8.5+'s lazy simple-query behavior. + pub fn enable_simple_streaming(&mut self) -> i64 { + self.simple_streaming = true; + 1 + } + + /// Overrides statement buffering before execution. + pub fn set_prefetch(&mut self, prefetch: bool) -> i64 { + if self.executed { + return 0; + } + self.buffered = prefetch; + 1 + } + + /// Resolves a named parameter to its one-based position. + pub fn bind_parameter_index(&self, name: &str) -> i64 { + self.named_map + .get(name.strip_prefix(':').unwrap_or(name)) + .copied() + .unwrap_or(0) + } + + /// Stores one bound parameter. + pub fn bind(&mut self, index: i64, value: Bind) -> i64 { + if index < 1 || index as usize > self.binds.len() { + return 0; + } + self.binds[index as usize - 1] = value; + self.bound[index as usize - 1] = true; + 1 + } + + /// Resets cursor/result state while preserving bindings. + pub fn reset(&mut self, conn: &mut PgConn) -> i64 { + if self.streaming { + while conn.client.result().is_some() {} + } + self.rows.clear(); + self.cursor = -1; + self.executed = false; + self.streaming = false; + 1 + } + + /// Clears every binding back to NULL/unbound. + pub fn clear_bindings(&mut self) -> i64 { + for (bind, bound) in self.binds.iter_mut().zip(&mut self.bound) { + *bind = Bind::Null; + *bound = false; + } + 1 + } + + /// Executes the statement in buffered or libpq single-row mode. + fn execute(&mut self, conn: &mut PgConn) -> Result<(), i64> { + if self.bound.iter().any(|bound| !bound) { + conn.sqlstate = "HY093".to_string(); + conn.errmsg = "Invalid parameter number".to_string(); + return Err(-1); + } + self.generation = conn.begin_query(); + let lazy = !self.buffered && (!self.emulated || self.simple_streaming); + if self.emulated { + let sql = interpolate_emulated_sql(&self.translated_sql, &self.markers, &self.binds) + .map_err(|message| { + conn.errmsg = message; + conn.sqlstate = "HY093".to_string(); + -1 + })?; + self.sent_sql = sql.clone(); + if lazy { + conn.client.send_query(&sql).map_err(|_| -1)?; + conn.client.set_single_row_mode().map_err(|_| -1)?; + conn.changes = 0; + conn.errcode = 0; + conn.errmsg.clear(); + conn.sqlstate = "00000".to_string(); + self.streaming = true; + self.executed = true; + return Ok(()); + } + let result = conn.client.exec(&sql); + return self.consume_buffered(conn, result); + } + let owned: Vec<(Option>, Format)> = self.binds.iter().map(bind_bytes).collect(); + let values: Vec> = owned + .iter() + .map(|(value, _)| value.as_deref()) + .collect(); + let formats: Vec = owned.iter().map(|(_, format)| *format).collect(); + let name = self.statement_name.as_deref(); + if lazy { + conn.client + .send_query_prepared(name, &values, &formats, Format::Text) + .map_err(|_| -1)?; + conn.client.set_single_row_mode().map_err(|_| -1)?; + conn.changes = 0; + conn.errcode = 0; + conn.errmsg.clear(); + conn.sqlstate = "00000".to_string(); + self.streaming = true; + self.executed = true; + return Ok(()); + } + let result = conn + .client + .exec_prepared(name, &values, &formats, Format::Text); + self.consume_buffered(conn, result) + } + + /// Copies a completed libpq result into statement-owned rows and metadata. + fn consume_buffered(&mut self, conn: &mut PgConn, result: PQResult) -> Result<(), i64> { + if !result_ok(&result) { + return Err(record_result_error(conn, &result)); + } + self.columns = result_columns(&conn.client, &result, true); + self.rows = (0..result.ntuples()) + .map(|row| decode_result_row(&result, row)) + .collect(); + let changes = if self.rows.is_empty() { + result.cmd_tuples().unwrap_or(0) as i64 + } else { + self.rows.len() as i64 + }; + conn.succeed(&self.query_string, changes); + self.executed = true; + Ok(()) + } + + /// Advances a buffered or single-row-mode cursor. + pub fn step(&mut self, conn: &mut PgConn) -> i64 { + if self.executed && self.generation != conn.generation { + return 0; + } + if !self.executed && self.execute(conn).is_err() { + return -1; + } + if self.streaming { + loop { + let Some(result) = conn.client.result() else { + self.streaming = false; + conn.succeed(&self.query_string, conn.changes); + return 0; + }; + if !result_ok(&result) { + self.streaming = false; + return record_result_error(conn, &result); + } + if result.status() == Status::SingleTuple && result.ntuples() == 1 { + if self.emulated || self.columns.is_empty() { + self.columns = result_columns(&conn.client, &result, false); + } + self.rows.clear(); + self.rows.push(decode_result_row(&result, 0)); + self.cursor = 0; + return 1; + } + if result.status() == Status::CommandOk { + conn.changes = result.cmd_tuples().unwrap_or(0) as i64; + } + } + } + self.cursor += 1; + ((self.cursor as usize) < self.rows.len()) as i64 + } + + /// Applies PDO cursor orientation to buffered results; streaming remains forward-only. + pub fn step_oriented(&mut self, conn: &mut PgConn, orientation: i64, offset: i64) -> i64 { + if self.streaming || !self.executed { + return self.step(conn); + } + let target = match orientation { + 0 => self.cursor + 1, + 1 => self.cursor - 1, + 2 => 0, + 3 => self.rows.len() as isize - 1, + 4 if offset > 0 => offset as isize - 1, + 4 if offset < 0 => self.rows.len() as isize + offset as isize, + 4 => -1, + 5 => self.cursor + offset as isize, + _ => return 0, + }; + if target < 0 { + self.cursor = -1; + return 0; + } + if target as usize >= self.rows.len() { + self.cursor = self.rows.len() as isize; + return 0; + } + self.cursor = target; + 1 + } + + /// Returns the current cell at a zero-based column index. + fn cell(&self, index: i64) -> Option<&Cell> { + self.rows + .get(self.cursor.max(0) as usize) + .and_then(|row| row.get(index as usize)) + } + + /// Returns the result column count. + pub fn column_count(&self) -> i64 { + if self.emulated && !self.executed && self.columns.is_empty() { + 1 + } else { + self.columns.len() as i64 + } + } + + /// Returns a result column name. + pub fn column_name(&self, index: i64) -> String { + self.columns.get(index as usize).map(|column| column.name.clone()).unwrap_or_default() + } + + /// Returns a source table name. + pub fn column_table_name(&self, index: i64) -> String { + self.columns.get(index as usize).map(|column| column.table_name.clone()).unwrap_or_default() + } + + /// Returns the bridge storage type code for the current value. + pub fn column_type(&self, index: i64) -> i64 { + match self.cell(index) { + Some(Cell::Int(_)) => 1, + Some(Cell::Float(_)) => 2, + Some(Cell::Text(_)) => 3, + Some(Cell::Bytes(_)) => 4, + _ => 5, + } + } + + /// Estimates currently statement-owned result memory. + pub fn result_memory_size(&self) -> Option { + self.executed.then(|| { + self.rows + .iter() + .flatten() + .map(|cell| match cell { + Cell::Text(value) => value.capacity(), + Cell::Bytes(value) => value.capacity(), + _ => std::mem::size_of::(), + }) + .sum::() as i64 + }) + } + + /// Returns PostgreSQL's native type name. + pub fn column_native_type(&self, index: i64) -> String { + self.columns.get(index as usize).map(|column| column.native_type.clone()).unwrap_or_default() + } + + /// Returns PostgreSQL's type OID. + pub fn column_type_oid(&self, index: i64) -> i64 { + self.columns.get(index as usize).map(|column| column.type_oid).unwrap_or(0) + } + + /// Returns PostgreSQL's source table OID. + pub fn column_table_oid(&self, index: i64) -> i64 { + self.columns.get(index as usize).map(|column| column.table_oid).unwrap_or(0) + } + + /// Returns libpq's PQfsize metadata value. + pub fn column_len(&self, index: i64) -> i64 { + self.columns.get(index as usize).map(|column| column.len).unwrap_or(-1) + } + + /// Returns libpq's raw PQfmod metadata value. + pub fn column_precision(&self, index: i64) -> i64 { + self.columns.get(index as usize).map(|column| column.precision).unwrap_or(-1) + } + + /// Returns the current value coerced to integer. + pub fn column_int(&self, index: i64) -> i64 { + match self.cell(index) { + Some(Cell::Int(value)) => *value, + Some(Cell::Float(value)) => *value as i64, + Some(Cell::Text(value)) => value.parse().unwrap_or(0), + _ => 0, + } + } + + /// Returns the current value coerced to float. + pub fn column_double(&self, index: i64) -> f64 { + match self.cell(index) { + Some(Cell::Float(value)) => *value, + Some(Cell::Int(value)) => *value as f64, + Some(Cell::Text(value)) => value.parse().unwrap_or(0.0), + _ => 0.0, + } + } + + /// Returns the current value as binary-safe bytes. + pub fn column_data(&self, index: i64) -> Vec { + match self.cell(index) { + Some(Cell::Text(value)) => value.as_bytes().to_vec(), + Some(Cell::Bytes(value)) => value.clone(), + Some(Cell::Int(value)) => value.to_string().into_bytes(), + Some(Cell::Float(value)) => value.to_string().into_bytes(), + _ => Vec::new(), + } + } +} + +/// Encodes bytes as lowercase hexadecimal SQL text. +fn hex_bytes(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Decodes lowercase/uppercase hexadecimal result bytes. +fn decode_hex(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(2) + .filter_map(|pair| std::str::from_utf8(pair).ok()) + .filter_map(|pair| u8::from_str_radix(pair, 16).ok()) + .collect() +} + +#[cfg(test)] +mod tests { + //! Purpose: + //! Unit tests for libpq-only connection-option forwarding. + //! + //! Called from: + //! - `cargo test -p elephc-pdo --features libpq-gss`. + //! + //! Key details: + //! - These tests require no server; live libpq execution is covered separately. + + use super::{bind_bytes, libpq_conninfo, Bind, Format}; + + /// GSS, authentication policy, encrypted-key password, and replication reach libpq intact. + #[test] + fn libpq_only_options_are_forwarded() { + let conninfo = libpq_conninfo( + "pgsql:host=db;user=app;service=kerberos;passfile=/secure/pgpass;gssencmode=require;require_auth=gss;sslpassword=secret;replication=database", + ) + .expect("libpq options render"); + assert!(conninfo.contains("gssencmode='require'")); + assert!(conninfo.contains("require_auth='gss'")); + assert!(conninfo.contains("sslpassword='secret'")); + assert!(conninfo.contains("replication='database'")); + assert!(conninfo.contains("service='kerberos'")); + assert!(conninfo.contains("passfile='/secure/pgpass'")); + assert!(conninfo.contains("connect_timeout='30'")); + } + + /// Text-format parameters satisfy libpq's C-string contract while binary + /// values retain their exact bytes, including any trailing zero. + #[test] + fn bind_buffers_use_the_required_libpq_termination() { + let (text, text_format) = bind_bytes(&Bind::Text("Ada".to_string())); + assert_eq!(text_format, Format::Text); + assert_eq!(text.as_deref(), Some(b"Ada\0".as_slice())); + + let (binary, binary_format) = bind_bytes(&Bind::Bytes(vec![b'A', 0, b'B'])); + assert_eq!(binary_format, Format::Binary); + assert_eq!(binary.as_deref(), Some([b'A', 0, b'B'].as_slice())); + } +} diff --git a/crates/elephc-pdo/src/sqlite.rs b/crates/elephc-pdo/src/sqlite.rs index d1db60f357..2f473760ba 100644 --- a/crates/elephc-pdo/src/sqlite.rs +++ b/crates/elephc-pdo/src/sqlite.rs @@ -13,27 +13,161 @@ //! dependency. //! - Column type codes match SQLite's: 1=INTEGER, 2=FLOAT, 3=TEXT, 4=BLOB, //! 5=NULL — the same codes the PDO prelude's `columnValue()` reads. +//! - Handle ownership: `SqliteConn` owns its `sqlite3*` and `SqliteStmt` owns its +//! `sqlite3_stmt*` (it only *borrows* the connection's `sqlite3*`). Each native +//! handle is released exactly once — by the explicit `close()` / `finalize()` +//! that `lib.rs` calls, with an `impl Drop` as the structural safety net for any +//! path that drops one of these values without calling them. +//! - Thread safety: the bridge locks its connection and statement tables under two +//! SEPARATE mutexes, so overlapping calls on one `sqlite3*` are only defined +//! under a mutexed SQLite build; `assert_sqlite_threadsafe` pins that invariant +//! at the first open. +use std::cell::{Cell, RefCell}; use std::ffi::{CStr, CString}; -use std::os::raw::{c_char, c_int}; +use std::os::raw::{c_char, c_int, c_void}; use std::ptr; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Once}; use libsqlite3_sys as ffi; +use crate::ffi_guard; + /// A live SQLite connection. The raw pointer is `Send` in practice because /// elephc programs drive one connection from one thread at a time. +/// +/// The struct OWNS its `sqlite3*`: the handle is released exactly once, either by +/// the explicit `close()` (`elephc_pdo_close`'s only call site, which has to run +/// first because it finalizes the connection's statements) or, failing that, by +/// the `Drop` net below. `released` is what makes those two idempotent with +/// respect to each other. It is a separate flag rather than a null-out of `db` +/// because `close()` only ever holds a `&self` — `lib.rs` reaches it through +/// `HashMap::get` on the connection table — and because `db` has to stay a plain +/// `*mut` field that `lib.rs` can read out by `Copy` to decide which registered +/// statements belong to this connection. pub struct SqliteConn { + /// The owned native connection handle. pub db: *mut ffi::sqlite3, + /// Whether `db` has already been handed back to SQLite (see the type docs). + released: Cell, + /// Transaction opening mode used by the next `PDO::beginTransaction()` call. + transaction_mode: Cell, + /// Authorizer callback registration owned by this connection. SQLite's + /// authorizer API has no destructor hook, so replacement/reset/close free it + /// explicitly rather than using the UDF registration path's `x_destroy`. + authorizer: Cell<*mut AuthorizerReg>, + /// Deferred PHP error classification produced inside SQLite's C callback. + authorizer_error: Arc, + /// Successfully registered collation names whose native callbacks must be + /// removed before PHP releases their callable descriptor roots. + collations: RefCell>, + /// Successfully registered scalar/aggregate `(name, arity)` pairs. SQLite + /// shares one namespace for both forms, so one key tracks either registration. + functions: RefCell>, } unsafe impl Send for SqliteConn {} +impl Drop for SqliteConn { + /// Defense-in-depth release of the native handle, alongside (not instead of) + /// the explicit `close()`. Rust's default drop of a bare raw pointer is a + /// no-op, so without this a future path that merely drops a `SqliteConn` — one + /// built but never registered, or removed from the connection table some other + /// way — would leak the handle with no crash and no warning. + /// + /// Sound because a `SqliteConn` is never cloned or copied (it has no such impl, + /// and a `Drop` type cannot be `Copy`) and is only ever *moved* — into + /// `Conn::Sqlite`, then into the connection table — and a move never drops its + /// source. Both release paths go through `released`, so after a successful + /// `close()` this is a no-op and `elephc_pdo_close`'s close-then-remove sequence + /// still frees the handle exactly once. + fn drop(&mut self) { + if self.released.replace(true) || self.db.is_null() { + return; + } + self.clear_callbacks(); + // `sqlite3_close` (not `_v2`) declines with SQLITE_BUSY when statements are + // still live rather than freeing the handle underneath them, so this net can + // never yank a db out from under a statement that is still registered: at + // worst it degrades to the very leak it exists to prevent, never to a + // use-after-free. + unsafe { ffi::sqlite3_close(self.db) }; + self.db = ptr::null_mut(); + } +} + /// A live SQLite prepared statement plus the connection pointer it belongs to. +/// +/// The struct OWNS `ptr` but only BORROWS `db`, which stays owned by the +/// `SqliteConn` — hence the asymmetry in `Drop`, which finalizes the statement and +/// never touches the connection. `released` guards `ptr` exactly as `SqliteConn`'s +/// flag guards `db`. pub struct SqliteStmt { + /// The owned native statement handle. pub ptr: *mut ffi::sqlite3_stmt, + /// The connection the statement was prepared on. Borrowed, never released here: + /// the error accessors need it because SQLite tracks error state per-connection. pub db: *mut ffi::sqlite3, + /// Whether `ptr` has already been handed back to SQLite (see the type docs). + released: Cell, } unsafe impl Send for SqliteStmt {} +impl Drop for SqliteStmt { + /// Defense-in-depth finalize of the native statement, alongside (not instead of) + /// the explicit `finalize()`, for the same reason `SqliteConn`'s `Drop` exists: + /// a dropped raw pointer releases nothing. + /// + /// Sound because a `SqliteStmt` is never cloned or copied and is only ever moved + /// (out of `prepare`, into `Stmt::Sqlite`, into the statement table), because + /// `released` makes it a no-op after `elephc_pdo_finalize`'s explicit + /// `finalize()`, and because it releases only `ptr` — `db` is the connection's + /// handle, owned by `SqliteConn`. `sqlite3_finalize` also needs its connection + /// still open, which holds on every path: `elephc_pdo_finalize` drops one + /// statement while its connection stays registered, `elephc_pdo_close` finalizes + /// and drops *every* statement of a connection before closing it, and the global + /// tables are `OnceLock` statics that are never dropped at process exit, so no + /// teardown order can invert that. + fn drop(&mut self) { + if self.released.replace(true) || self.ptr.is_null() { + return; + } + unsafe { ffi::sqlite3_finalize(self.ptr) }; + self.ptr = ptr::null_mut(); + } +} + +/// Checks once, at the first connection open, that the linked SQLite is not the +/// mutex-free build. The bridge locks its connection table and its statement table +/// under two SEPARATE mutexes (`lib.rs`'s `conns()` / `stmts()`), so nothing stops +/// an `sqlite3_step()` driven from one thread from overlapping an `sqlite3_exec()` +/// on the same `sqlite3*` from another; that overlap is only defined when SQLite +/// serializes API entry on the connection's own mutex (`SQLITE_THREADSAFE=1`). +/// `libsqlite3-sys`'s `bundled` feature — which this crate pins — compiles the +/// amalgamation with `-DSQLITE_THREADSAFE=1`, so the invariant holds by +/// construction today; the assertion pins it against a later switch to a system +/// SQLite or a hand-set compile flag, which would otherwise corrupt data silently +/// instead of failing. `sqlite3_threadsafe()` only reports whether the mutex code +/// was compiled in, so it rules out the single-threaded build (the one that is +/// actually unsound here) rather than proving the serialized mode specifically — +/// the mutex-free build is the only variant `bundled` could realistically produce. +/// +/// The check is skipped on wasm, where `libsqlite3-sys` deliberately builds the +/// amalgamation with `SQLITE_THREADSAFE=0`: that target has no threads, so the +/// overlap this invariant guards against cannot arise there. +fn assert_sqlite_threadsafe() { + static CHECKED: Once = Once::new(); + CHECKED.call_once(|| { + #[cfg(not(target_family = "wasm"))] + assert!( + unsafe { ffi::sqlite3_threadsafe() } != 0, + "elephc-pdo requires a thread-safe SQLite build: the bridge locks its connection \ + and statement tables separately, so calls on one sqlite3* can overlap across \ + threads, which is only safe under SQLITE_THREADSAFE=1 (serialized)", + ); + }); +} + /// Reads SQLite's current error message for a connection into an owned `String`. unsafe fn read_errmsg(db: *mut ffi::sqlite3) -> String { let p = ffi::sqlite3_errmsg(db); @@ -43,15 +177,52 @@ unsafe fn read_errmsg(db: *mut ffi::sqlite3) -> String { CStr::from_ptr(p).to_string_lossy().into_owned() } +/// Maps a SQLite primary result code to its 5-char SQLSTATE, mirroring PHP's +/// `pdo_sqlite` driver (`ext/pdo_sqlite/sqlite_driver.c`, `pdo_sqlite_error`): +/// `SQLITE_NOTFOUND`/`SQLITE_INTERRUPT`/`SQLITE_NOLFS`/`SQLITE_TOOBIG`/ +/// `SQLITE_CONSTRAINT` get their own SQLSTATE, everything else (including +/// `SQLITE_ERROR`, `SQLITE_BUSY`, `SQLITE_LOCKED`, and the permission/read-only +/// family) falls back to the driver's generic `HY000`. `SQLITE_OK` is not part of +/// that error-only table — it is the bridge's own "no error" default, added here +/// so the mapping is total over every primary result code SQLite can report. +pub fn sqlite_sqlstate(rc: c_int) -> &'static str { + match rc { + ffi::SQLITE_OK => "00000", + ffi::SQLITE_NOTFOUND => "42S02", + ffi::SQLITE_INTERRUPT => "01002", + ffi::SQLITE_NOLFS => "HYC00", + ffi::SQLITE_TOOBIG => "22001", + ffi::SQLITE_CONSTRAINT => "23000", + _ => "HY000", + } +} + impl SqliteConn { /// Opens the SQLite database at `path` (the DSN body after `sqlite:`), /// returning the connection or an error message. - pub fn open(path: &str) -> Result { + /// + /// `open_flags` is the raw `sqlite3_open_v2` flags to use, taken from + /// `Pdo\Sqlite::ATTR_OPEN_FLAGS` (P1-10); `0` means "no override", which + /// keeps the default `READWRITE|CREATE` PHP uses when the option is not + /// set. `Pdo\Sqlite::OPEN_READONLY`/`OPEN_READWRITE`/`OPEN_CREATE` share + /// their bit values with `SQLITE_OPEN_READONLY`/`_READWRITE`/`_CREATE`, so + /// the PHP-side int crosses unchanged. When `path` starts with `file:` + /// (P2-9's URI DSN, e.g. `sqlite:file:test.db?mode=ro`), `SQLITE_OPEN_URI` + /// is OR-ed in regardless of `open_flags` so the query-string is honored. + pub fn open(path: &str, open_flags: i64) -> Result { + assert_sqlite_threadsafe(); let Ok(c_path) = CString::new(path) else { return Err("invalid database path".to_string()); }; let mut db: *mut ffi::sqlite3 = ptr::null_mut(); - let flags = ffi::SQLITE_OPEN_READWRITE | ffi::SQLITE_OPEN_CREATE; + let mut flags: c_int = if open_flags != 0 { + open_flags as c_int + } else { + ffi::SQLITE_OPEN_READWRITE | ffi::SQLITE_OPEN_CREATE + }; + if path.starts_with("file:") { + flags |= ffi::SQLITE_OPEN_URI; + } let rc = unsafe { ffi::sqlite3_open_v2(c_path.as_ptr(), &mut db, flags, ptr::null()) }; if rc != ffi::SQLITE_OK { let msg = if db.is_null() { @@ -64,23 +235,36 @@ impl SqliteConn { } return Err(msg); } - Ok(SqliteConn { db }) + // P2-7: PHP's pdo_sqlite seeds a 60s busy-timeout at connect time so a + // lock contention (another connection mid-write) retries instead of + // failing immediately with SQLITE_BUSY. `ATTR_TIMEOUT`/`setAttribute` + // still override this later via `set_busy_timeout`. + unsafe { ffi::sqlite3_busy_timeout(db, 60_000) }; + Ok(SqliteConn { + db, + released: Cell::new(false), + transaction_mode: Cell::new(0), + authorizer: Cell::new(ptr::null_mut()), + authorizer_error: Arc::new(AtomicI64::new(0)), + collations: RefCell::new(Vec::new()), + functions: RefCell::new(Vec::new()), + }) } - /// Runs one or more statements with no result rows (`PDO::exec`). Returns the - /// number of rows changed, or `-1` on error. + /// Runs SQL on a borrowed native connection pointer without requiring the + /// bridge's connection-table lock to remain held across SQLite callbacks. /// /// # Safety - /// `sql` must point to a NUL-terminated string valid for the call. - pub unsafe fn exec(&self, sql: *const c_char) -> i64 { + /// `db` must be a live SQLite connection and `sql` a valid NUL-terminated string. + pub unsafe fn exec_on(db: *mut ffi::sqlite3, sql: *const c_char) -> i64 { if sql.is_null() { return -1; } - let rc = ffi::sqlite3_exec(self.db, sql, None, ptr::null_mut(), ptr::null_mut()); + let rc = ffi::sqlite3_exec(db, sql, None, ptr::null_mut(), ptr::null_mut()); if rc != ffi::SQLITE_OK { return -1; } - ffi::sqlite3_changes(self.db) as i64 + ffi::sqlite3_changes(db) as i64 } /// Returns the rowid of the most recent successful INSERT. @@ -93,6 +277,18 @@ impl SqliteConn { unsafe { ffi::sqlite3_changes(self.db) as i64 } } + /// Returns whether the connection is currently inside a transaction, read + /// live from SQLite's own autocommit flag (`sqlite3_get_autocommit`) rather + /// than any PHP-side bookkeeping (P1-g). SQLite reports non-autocommit (`0`) + /// from the moment a `BEGIN` — issued via `PDO::beginTransaction()` OR a raw + /// `PDO::exec("BEGIN")` — takes effect until the matching `COMMIT`/`ROLLBACK` + /// (or an auto-rollback on error), so this mirrors php-src's own + /// `pdo_sqlite3_in_transaction` handler exactly. Returns `1` when a + /// transaction is active, `0` otherwise. + pub fn in_transaction(&self) -> i64 { + (unsafe { ffi::sqlite3_get_autocommit(self.db) } == 0) as i64 + } + /// Runs a single bare statement (BEGIN/COMMIT/ROLLBACK), returning `1`/`0`. pub fn exec_simple(&self, sql: &[u8]) -> i64 { let Ok(c_sql) = CString::new(sql) else { @@ -110,6 +306,30 @@ impl SqliteConn { (rc == ffi::SQLITE_OK) as i64 } + /// Begins a transaction with the configured PHP 8.5 SQLite transaction mode. + pub fn begin_transaction(&self) -> i64 { + let sql = match self.transaction_mode.get() { + 1 => b"BEGIN IMMEDIATE".as_slice(), + 2 => b"BEGIN EXCLUSIVE".as_slice(), + _ => b"BEGIN DEFERRED".as_slice(), + }; + self.exec_simple(sql) + } + + /// Stores a validated PHP 8.5 SQLite transaction mode, returning `1` on success. + pub fn set_transaction_mode(&self, mode: i64) -> i64 { + if !(0..=2).contains(&mode) { + return 0; + } + self.transaction_mode.set(mode); + 1 + } + + /// Returns the configured PHP 8.5 SQLite transaction mode. + pub fn transaction_mode(&self) -> i64 { + self.transaction_mode.get() + } + /// Returns SQLite's primary result code for the connection's last operation. pub fn errcode(&self) -> i64 { unsafe { ffi::sqlite3_errcode(self.db) as i64 } @@ -120,33 +340,1203 @@ impl SqliteConn { unsafe { read_errmsg(self.db) } } - /// Prepares a statement, returning the statement handle or `()` on error. + /// Prepares SQL on a borrowed native connection pointer without retaining the + /// bridge's connection-table lock while an authorizer callback runs. /// /// # Safety - /// `sql` must point to a NUL-terminated string valid for the call. - pub unsafe fn prepare(&self, sql: *const c_char) -> Result { + /// `db` must be a live SQLite connection and `sql` a valid NUL-terminated string. + pub unsafe fn prepare_on( + db: *mut ffi::sqlite3, + sql: *const c_char, + ) -> Result { if sql.is_null() { return Err(()); } let mut stmt: *mut ffi::sqlite3_stmt = ptr::null_mut(); // -1 length lets SQLite read up to the NUL terminator. - let rc = ffi::sqlite3_prepare_v2(self.db, sql, -1, &mut stmt, ptr::null_mut()); + let rc = ffi::sqlite3_prepare_v2(db, sql, -1, &mut stmt, ptr::null_mut()); if rc != ffi::SQLITE_OK || stmt.is_null() { return Err(()); } Ok(SqliteStmt { ptr: stmt, - db: self.db, + db, + released: Cell::new(false), }) } - /// Closes the connection (the caller finalizes its statements first). + /// Closes the connection (the caller finalizes its statements first), releasing + /// the native handle. Idempotent: a second call — or the `Drop` net — is a no-op + /// once SQLite has taken the handle back, so it is freed exactly once. pub fn close(&self) { - unsafe { ffi::sqlite3_close(self.db) }; + if self.released.get() || self.db.is_null() { + return; + } + self.clear_callbacks(); + // Only SQLITE_OK means SQLite actually freed the handle. Anything else + // (SQLITE_BUSY: a statement of this connection outlived the caller's + // finalize loop) leaves it live and un-released, so `Drop` still gets a shot + // at it — re-closing a live handle is safe, re-closing a freed one would be + // a use-after-free. + if unsafe { ffi::sqlite3_close(self.db) } == ffi::SQLITE_OK { + self.released.set(true); + } + } + + /// Returns the 5-char SQLSTATE for the connection's last operation. + pub fn sqlstate(&self) -> String { + sqlite_sqlstate(unsafe { ffi::sqlite3_errcode(self.db) }).to_string() + } + + /// Sets the number of milliseconds SQLite retries a locked database before + /// giving up with `SQLITE_BUSY` (`sqlite3_busy_timeout`). Returns `1`/`0`. + pub fn set_busy_timeout(&self, ms: i64) -> i64 { + let rc = unsafe { ffi::sqlite3_busy_timeout(self.db, ms as c_int) }; + (rc == ffi::SQLITE_OK) as i64 + } + + /// Turns SQLite's extended result codes on (`on != 0`) or off, backing + /// `PDO::setAttribute(Pdo\Sqlite::ATTR_EXTENDED_RESULT_CODES, …)`. php-src's + /// `pdo_sqlite_set_attr` (`ext/pdo_sqlite/sqlite_driver.c`) makes exactly this + /// `sqlite3_extended_result_codes(H->db, lval)` call: with extended codes on, + /// `sqlite3_errcode()` — the value PDO reports as `errorInfo[1]` — returns the + /// refined code (2067 `SQLITE_CONSTRAINT_UNIQUE`) where it would otherwise + /// return the primary one (19 `SQLITE_CONSTRAINT`). Returns `1` on `SQLITE_OK`, + /// `0` otherwise. + /// + /// `sqlite_sqlstate` is deliberately left keying off the unmasked code, so an + /// extended code falls through its match to the generic `HY000`. That is not an + /// oversight: php-src's `pdo_sqlite_error` switches on the same unmasked + /// `sqlite3_errcode()` value, so its SQLSTATE degrades identically once the + /// attribute is on. + pub fn set_extended_result_codes(&self, on: i64) -> i64 { + let rc = unsafe { ffi::sqlite3_extended_result_codes(self.db, (on != 0) as c_int) }; + (rc == ffi::SQLITE_OK) as i64 + } + + /// Returns the bundled SQLite library's version string (e.g. `"3.46.0"`). + pub fn server_version(&self) -> String { + unsafe { + let p = ffi::sqlite3_libversion(); + if p.is_null() { + String::new() + } else { + CStr::from_ptr(p).to_string_lossy().into_owned() + } + } + } + + /// Returns SQLite's linked library version, which php-src exposes identically + /// for both its client- and server-version attributes. + pub fn client_version(&self) -> String { + self.server_version() + } + + /// Loads the SQLite extension at `path` (its entry point auto-derived, as PHP's + /// `Pdo\Sqlite::loadExtension()` does), returning 1 on success or 0 on error. + /// Extension loading is enabled only for the duration of the call and disabled + /// again afterward to keep the default hardened posture. The freed error message + /// is discarded (the caller reports failure via the connection's error state / + /// a thrown exception). + /// + /// # Safety + /// Loading an extension executes arbitrary native code from `path`; the caller + /// is trusted to supply a library it intends to run. + pub fn load_extension(&self, path: &str) -> i64 { + let Ok(c_path) = CString::new(path) else { + return 0; + }; + unsafe { + ffi::sqlite3_enable_load_extension(self.db, 1); + let mut errmsg: *mut c_char = ptr::null_mut(); + let rc = + ffi::sqlite3_load_extension(self.db, c_path.as_ptr(), ptr::null(), &mut errmsg); + ffi::sqlite3_enable_load_extension(self.db, 0); + if !errmsg.is_null() { + ffi::sqlite3_free(errmsg as *mut _); + } + (rc == ffi::SQLITE_OK) as i64 + } + } + + /// Reads a BLOB cell whole through the incremental-blob API + /// (`sqlite3_blob_open` read-only, `sqlite3_blob_bytes`, `sqlite3_blob_read`), + /// returning its raw bytes. `dbname` selects the attached database ("main" by + /// default), `rowid` is the row's integer key, and `column` names the BLOB + /// column. A missing row/column, or a column that cannot be opened as a blob, + /// surfaces as `Err(message)`. Backs the initial snapshot used by + /// `Pdo\Sqlite::openBlob()`'s seekable stream wrapper. + pub fn blob_read( + &self, + dbname: &str, + table: &str, + column: &str, + rowid: i64, + ) -> Result, String> { + let c_db = CString::new(dbname).map_err(|_| "invalid database name".to_string())?; + let c_table = CString::new(table).map_err(|_| "invalid table name".to_string())?; + let c_col = CString::new(column).map_err(|_| "invalid column name".to_string())?; + unsafe { + let mut blob: *mut ffi::sqlite3_blob = ptr::null_mut(); + // flags = 0 is sufficient for the wrapper's initial snapshot; writable + // range updates reopen the same cell through `blob_write`. + let rc = ffi::sqlite3_blob_open( + self.db, + c_db.as_ptr(), + c_table.as_ptr(), + c_col.as_ptr(), + rowid, + 0, + &mut blob, + ); + if rc != ffi::SQLITE_OK || blob.is_null() { + return Err(read_errmsg(self.db)); + } + let n = ffi::sqlite3_blob_bytes(blob); + let mut buf = vec![0u8; n.max(0) as usize]; + let read_rc = if n > 0 { + ffi::sqlite3_blob_read(blob, buf.as_mut_ptr() as *mut c_void, n, 0) + } else { + ffi::SQLITE_OK + }; + // Capture the error text before closing, since close resets the handle. + let err = (read_rc != ffi::SQLITE_OK).then(|| read_errmsg(self.db)); + ffi::sqlite3_blob_close(blob); + match err { + Some(msg) => Err(msg), + None => Ok(buf), + } + } + } + + /// Replaces the bytes of an existing SQLite BLOB through the incremental-blob + /// API. SQLite cannot resize an incremental BLOB, so `data` must have exactly + /// the cell's existing byte length; callers implement partial writes by first + /// reading the cell, patching that snapshot, and sending the full fixed-size + /// value back. Returns `Ok(())` on success and the live SQLite error otherwise. + pub fn blob_write( + &self, + dbname: &str, + table: &str, + column: &str, + rowid: i64, + data: &[u8], + ) -> Result<(), String> { + let c_db = CString::new(dbname).map_err(|_| "invalid database name".to_string())?; + let c_table = CString::new(table).map_err(|_| "invalid table name".to_string())?; + let c_col = CString::new(column).map_err(|_| "invalid column name".to_string())?; + unsafe { + let mut blob: *mut ffi::sqlite3_blob = ptr::null_mut(); + let rc = ffi::sqlite3_blob_open( + self.db, + c_db.as_ptr(), + c_table.as_ptr(), + c_col.as_ptr(), + rowid, + 1, + &mut blob, + ); + if rc != ffi::SQLITE_OK || blob.is_null() { + return Err(read_errmsg(self.db)); + } + let size = ffi::sqlite3_blob_bytes(blob).max(0) as usize; + if size != data.len() { + ffi::sqlite3_blob_close(blob); + return Err("It is not possible to increase the size of a BLOB".to_string()); + } + let write_rc = if data.is_empty() { + ffi::SQLITE_OK + } else { + ffi::sqlite3_blob_write( + blob, + data.as_ptr() as *const c_void, + data.len() as c_int, + 0, + ) + }; + let err = (write_rc != ffi::SQLITE_OK).then(|| read_errmsg(self.db)); + ffi::sqlite3_blob_close(blob); + match err { + Some(msg) => Err(msg), + None => Ok(()), + } + } + } + + /// Returns the current byte size of an existing SQLite BLOB without copying + /// its contents. The incremental handle is opened read-only and closed before + /// returning, so callers retain no native resource between stream operations. + pub fn blob_size( + &self, + dbname: &str, + table: &str, + column: &str, + rowid: i64, + ) -> Result { + let c_db = CString::new(dbname).map_err(|_| "invalid database name".to_string())?; + let c_table = CString::new(table).map_err(|_| "invalid table name".to_string())?; + let c_col = CString::new(column).map_err(|_| "invalid column name".to_string())?; + unsafe { + let mut blob: *mut ffi::sqlite3_blob = ptr::null_mut(); + let rc = ffi::sqlite3_blob_open( + self.db, + c_db.as_ptr(), + c_table.as_ptr(), + c_col.as_ptr(), + rowid, + 0, + &mut blob, + ); + if rc != ffi::SQLITE_OK || blob.is_null() { + return Err(read_errmsg(self.db)); + } + let size = ffi::sqlite3_blob_bytes(blob).max(0) as i64; + ffi::sqlite3_blob_close(blob); + Ok(size) + } + } + + /// Reads at most `length` bytes from an existing SQLite BLOB at `offset`. + /// Reads are clipped at the cell's fixed end and never allocate more than the + /// requested slice; negative offsets or lengths are rejected. + pub fn blob_read_at( + &self, + dbname: &str, + table: &str, + column: &str, + rowid: i64, + offset: i64, + length: i64, + ) -> Result, String> { + if offset < 0 || length < 0 || offset > c_int::MAX as i64 { + return Err("invalid BLOB read range".to_string()); + } + let c_db = CString::new(dbname).map_err(|_| "invalid database name".to_string())?; + let c_table = CString::new(table).map_err(|_| "invalid table name".to_string())?; + let c_col = CString::new(column).map_err(|_| "invalid column name".to_string())?; + unsafe { + let mut blob: *mut ffi::sqlite3_blob = ptr::null_mut(); + let rc = ffi::sqlite3_blob_open( + self.db, + c_db.as_ptr(), + c_table.as_ptr(), + c_col.as_ptr(), + rowid, + 0, + &mut blob, + ); + if rc != ffi::SQLITE_OK || blob.is_null() { + return Err(read_errmsg(self.db)); + } + let size = ffi::sqlite3_blob_bytes(blob).max(0) as i64; + let available = size.saturating_sub(offset); + let read_len = length.min(available).min(c_int::MAX as i64) as usize; + let mut buf = vec![0u8; read_len]; + let read_rc = if read_len == 0 { + ffi::SQLITE_OK + } else { + ffi::sqlite3_blob_read( + blob, + buf.as_mut_ptr() as *mut c_void, + read_len as c_int, + offset as c_int, + ) + }; + let err = (read_rc != ffi::SQLITE_OK).then(|| read_errmsg(self.db)); + ffi::sqlite3_blob_close(blob); + match err { + Some(msg) => Err(msg), + None => Ok(buf), + } + } + } + + /// Writes `data` into an existing fixed-size SQLite BLOB at `offset` and + /// returns the number of bytes written. SQLite cannot extend an incremental + /// BLOB, so any range crossing the current end is rejected before mutation. + pub fn blob_write_at( + &self, + dbname: &str, + table: &str, + column: &str, + rowid: i64, + offset: i64, + data: &[u8], + ) -> Result { + if offset < 0 || offset > c_int::MAX as i64 || data.len() > c_int::MAX as usize { + return Err("invalid BLOB write range".to_string()); + } + let c_db = CString::new(dbname).map_err(|_| "invalid database name".to_string())?; + let c_table = CString::new(table).map_err(|_| "invalid table name".to_string())?; + let c_col = CString::new(column).map_err(|_| "invalid column name".to_string())?; + unsafe { + let mut blob: *mut ffi::sqlite3_blob = ptr::null_mut(); + let rc = ffi::sqlite3_blob_open( + self.db, + c_db.as_ptr(), + c_table.as_ptr(), + c_col.as_ptr(), + rowid, + 1, + &mut blob, + ); + if rc != ffi::SQLITE_OK || blob.is_null() { + return Err(read_errmsg(self.db)); + } + let size = ffi::sqlite3_blob_bytes(blob).max(0) as i64; + let end = offset + .checked_add(data.len() as i64) + .ok_or_else(|| "invalid BLOB write range".to_string())?; + if end > size { + ffi::sqlite3_blob_close(blob); + return Err("It is not possible to increase the size of a BLOB".to_string()); + } + let write_rc = if data.is_empty() { + ffi::SQLITE_OK + } else { + ffi::sqlite3_blob_write( + blob, + data.as_ptr() as *const c_void, + data.len() as c_int, + offset as c_int, + ) + }; + let err = (write_rc != ffi::SQLITE_OK).then(|| read_errmsg(self.db)); + ffi::sqlite3_blob_close(blob); + match err { + Some(msg) => Err(msg), + None => Ok(data.len() as i64), + } + } + } + + /// Registers a custom collation `name` backed by a compiled-PHP comparator + /// (`Pdo\Sqlite::createCollation`). `descriptor` is the callable's 64-byte + /// descriptor pointer and `adapter` the address of the codegen collation + /// adapter (`__rt_pdo_call_collation`); both are threaded to the `x_compare` + /// dispatcher through SQLite's per-registration `pApp`, so any number of + /// collations coexist on one connection. Returns `1` on success, `0` on error. + /// + /// # Safety + /// `descriptor`/`adapter` must be the live callable descriptor and adapter + /// entry of the calling compiled program; both are kept alive by the PDO + /// object rooting the callable, so the bridge stores them without touching the + /// descriptor's (arena-managed) refcount. + pub unsafe fn create_collation( + &self, + name: &str, + descriptor: *mut c_void, + adapter: *const c_void, + ) -> i64 { + let Ok(c_name) = CString::new(name) else { + return 0; + }; + let reg = Box::into_raw(Box::new(UdfReg { descriptor, adapter })) as *mut c_void; + // `_v2` invokes `x_destroy` (freeing the box) even when it returns an + // error, so the success path is the only one that must not free here. + let rc = ffi::sqlite3_create_collation_v2( + self.db, + c_name.as_ptr(), + ffi::SQLITE_UTF8, + reg, + Some(x_compare), + Some(x_destroy), + ); + if rc == ffi::SQLITE_OK { + let mut collations = self.collations.borrow_mut(); + collations.retain(|registered| !registered.eq_ignore_ascii_case(name)); + collations.push(name.to_string()); + 1 + } else { + 0 + } + } + + /// Registers a scalar SQL function `name` backed by a compiled-PHP callable + /// (`Pdo\Sqlite::createFunction`). `num_args` is the declared arity (-1 = + /// variadic), `flags` an optional `SQLITE_DETERMINISTIC` OR-ed into the text + /// encoding, and `descriptor`/`adapter` the callable descriptor pointer and the + /// codegen scalar adapter (`__rt_pdo_call_scalar`) threaded to `x_scalar` through + /// SQLite's per-registration `pApp`. Returns `1` on success, `0` on error. + /// + /// # Safety + /// `descriptor`/`adapter` must be the live callable descriptor and adapter entry + /// of the calling compiled program; both are kept alive by the PDO object rooting + /// the callable, so the bridge stores them without touching the descriptor's + /// (arena-managed) refcount. + pub unsafe fn create_function( + &self, + name: &str, + num_args: i64, + flags: i64, + descriptor: *mut c_void, + adapter: *const c_void, + ) -> i64 { + let Ok(c_name) = CString::new(name) else { + return 0; + }; + let reg = Box::into_raw(Box::new(UdfReg { descriptor, adapter })) as *mut c_void; + // `_v2` invokes `x_destroy` (freeing the box) even on failure, so only the + // success path must not free here. `flags` carries SQLITE_DETERMINISTIC etc., + // OR-ed into the UTF-8 text encoding as SQLite's C API expects. + let rc = ffi::sqlite3_create_function_v2( + self.db, + c_name.as_ptr(), + num_args as c_int, + ffi::SQLITE_UTF8 | (flags as c_int), + reg, + Some(x_scalar), + None, + None, + Some(x_destroy), + ); + if rc == ffi::SQLITE_OK { + self.remember_function(name, num_args); + 1 + } else { + 0 + } + } + + /// Registers an aggregate SQL function `name` backed by a compiled-PHP step + + /// finalize pair (`Pdo\Sqlite::createAggregate`). `num_args` is the declared + /// arity (-1 = variadic); each callable is decomposed into a descriptor pointer + /// and the address of its codegen adapter (`__rt_pdo_call_agg_step` / + /// `__rt_pdo_call_agg_final`). All four pointers are boxed in an `AggReg` + /// threaded through SQLite's per-registration `pApp`; the per-group accumulator + /// lives in the aggregate context (`AggCtx`), not here. Returns `1` on success, + /// `0` on error. + /// + /// # Safety + /// `descriptor`/`adapter` pointers must be the live callable descriptors and + /// adapter entries of the calling compiled program; both callables are kept alive + /// by the PDO object rooting them, so the bridge stores them as bare pointers. + pub unsafe fn create_aggregate( + &self, + name: &str, + num_args: i64, + step_descriptor: *mut c_void, + step_adapter: *const c_void, + final_descriptor: *mut c_void, + final_adapter: *const c_void, + ) -> i64 { + let Ok(c_name) = CString::new(name) else { + return 0; + }; + let reg = Box::into_raw(Box::new(AggReg { + step_descriptor, + step_adapter, + final_descriptor, + final_adapter, + })) as *mut c_void; + // An aggregate supplies xStep + xFinal and NULL for xFunc. `_v2` invokes + // x_destroy_agg (freeing the box) even on failure, so only the success path + // must not free here. PDO's createAggregate has no DETERMINISTIC/flags arg, + // so the text encoding is a bare SQLITE_UTF8. + let rc = ffi::sqlite3_create_function_v2( + self.db, + c_name.as_ptr(), + num_args as c_int, + ffi::SQLITE_UTF8, + reg, + None, + Some(x_agg_step), + Some(x_agg_final), + Some(x_destroy_agg), + ); + if rc == ffi::SQLITE_OK { + self.remember_function(name, num_args); + 1 + } else { + 0 + } + } + + /// Installs a PHP 8.5 SQLite authorizer backed by a compiled-PHP callable. + /// The scalar callback adapter is reused because the authorizer's five values + /// use the same int/string/null argument shape and boxed scalar return ABI. + /// Replacing a callback releases the previous registration. Returns `1` on + /// success and `0` when SQLite rejects the registration. + /// + /// # Safety + /// `descriptor` and `adapter` must remain valid while the authorizer is + /// installed. The PDO prelude roots the descriptor for that lifetime. + pub unsafe fn set_authorizer( + &self, + descriptor: *mut c_void, + adapter: *const c_void, + ) -> i64 { + self.clear_authorizer(); + let reg = Box::into_raw(Box::new(AuthorizerReg { + descriptor, + adapter, + error: Arc::clone(&self.authorizer_error), + })); + let rc = ffi::sqlite3_set_authorizer(self.db, Some(x_authorizer), reg as *mut c_void); + if rc == ffi::SQLITE_OK { + self.authorizer.set(reg); + 1 + } else { + drop(Box::from_raw(reg)); + 0 + } + } + + /// Removes and frees the installed SQLite authorizer, if any. This is + /// idempotent and is used by nullable reset, replacement, close, and `Drop`. + pub fn clear_authorizer(&self) { + let reg = self.authorizer.replace(ptr::null_mut()); + unsafe { + ffi::sqlite3_set_authorizer(self.db, None, ptr::null_mut()); + if !reg.is_null() { + drop(Box::from_raw(reg)); + } + } + self.authorizer_error.store(0, Ordering::Release); + } + + /// Removes every callback registration before its compiled-PHP descriptor roots + /// are released. This is required even for persistent handles, which stay in the + /// pool after the owning PDO object is destroyed. + pub fn clear_callbacks(&self) { + self.clear_authorizer(); + let collations = self.collations.take(); + for name in collations { + let Ok(c_name) = CString::new(name) else { + continue; + }; + unsafe { + ffi::sqlite3_create_collation_v2( + self.db, + c_name.as_ptr(), + ffi::SQLITE_UTF8, + ptr::null_mut(), + None, + None, + ); + } + } + let functions = self.functions.take(); + for (name, num_args) in functions { + let Ok(c_name) = CString::new(name) else { + continue; + }; + unsafe { + ffi::sqlite3_create_function_v2( + self.db, + c_name.as_ptr(), + num_args as c_int, + ffi::SQLITE_UTF8, + ptr::null_mut(), + None, + None, + None, + None, + ); + } + } + } + + /// Records one successful scalar or aggregate registration, replacing the + /// case-insensitive SQLite key already occupying the same name and arity. + fn remember_function(&self, name: &str, num_args: i64) { + let mut functions = self.functions.borrow_mut(); + functions.retain(|(registered, arity)| { + *arity != num_args || !registered.eq_ignore_ascii_case(name) + }); + functions.push((name.to_string(), num_args)); + } + + /// Takes and clears a deferred authorizer callback error classification. + /// Zero means the callback returned a valid SQLite decision or did not run. + pub fn take_authorizer_error(&self) -> i64 { + self.authorizer_error.swap(0, Ordering::AcqRel) + } +} + +/// The C-ABI adapter that re-enters a compiled-PHP collation comparator. Emitted +/// by codegen as `__rt_pdo_call_collation`; the bridge only stores and calls its +/// address. It boxes the two byte buffers as PHP strings, invokes the callable +/// descriptor's uniform invoker, and returns the comparison sign clamped to +/// -1/0/1 (or a sentinel the dispatcher maps to "equal" when the comparator threw). +type CollationAdapter = unsafe extern "C" fn( + descriptor: *mut c_void, + a: *const u8, + a_len: i64, + b: *const u8, + b_len: i64, +) -> i64; + +/// A registered SQLite user callback. Boxed and handed to SQLite as the +/// registration's `pApp`, recovered in the dispatcher, and freed by `x_destroy` +/// at `sqlite3_close` / re-registration. The compiled-PHP callable `descriptor` +/// is kept alive by the PDO object rooting the callable (`$this->udfCallbacks`), +/// so the bridge holds it as a bare pointer and never touches its refcount (which +/// lives in the compiled program's arena, unreachable from this staticlib). +struct UdfReg { + /// The 64-byte compiled-PHP callable descriptor pointer. + descriptor: *mut c_void, + /// The shared codegen adapter entry that re-enters the descriptor. + adapter: *const c_void, +} + +/// SQLite authorizer registration with deferred PHP error state. The authorizer +/// API has no destructor hook, so `SqliteConn` owns and frees this box directly. +struct AuthorizerReg { + /// The 64-byte compiled-PHP callable descriptor pointer. + descriptor: *mut c_void, + /// The shared scalar callback adapter entry. + adapter: *const c_void, + /// Error classification consumed by the outer PDO method after SQLite returns. + error: Arc, +} + +/// SQLite collation dispatcher (`xCompare`). Recovers the `UdfReg` from `pApp` +/// and re-enters the compiled-PHP comparator through its codegen adapter, passing +/// the two byte buffers SQLite provides (not NUL-terminated — the adapter consumes +/// explicit lengths). Returns the comparison sign in -1/0/1. +/// +/// # Safety +/// `p_arg` is the `pApp` from registration (a live `Box` pointer); `a`/`b` +/// point to `n_a`/`n_b` bytes valid for the call. +unsafe extern "C" fn x_compare( + p_arg: *mut c_void, + n_a: c_int, + a: *const c_void, + n_b: c_int, + b: *const c_void, +) -> c_int { + if p_arg.is_null() { + return 0; + } + let reg = &*(p_arg as *const UdfReg); + let adapter: CollationAdapter = std::mem::transmute(reg.adapter); + let sign = adapter( + reg.descriptor, + a as *const u8, + n_a as i64, + b as *const u8, + n_b as i64, + ); + sign.clamp(-1, 1) as c_int +} + +/// Frees a `Box` when SQLite deletes a registration (connection close or +/// re-registration under the same name). Registered as every callback's `xDestroy`. +/// +/// # Safety +/// `p_arg` must be a pointer produced by `Box::into_raw` for a `UdfReg`. +unsafe extern "C" fn x_destroy(p_arg: *mut c_void) { + if !p_arg.is_null() { + drop(Box::from_raw(p_arg as *mut UdfReg)); + } +} + +/// One argument value crossing from the bridge's `x_scalar` shim into the codegen +/// scalar adapter (`__rt_pdo_call_scalar`). A fixed `#[repr(C)]` POD so the adapter +/// can read fields by offset; `tag` selects which payload field is live. `ptr`/`len` +/// alias SQLite's `sqlite3_value` buffers, which stay valid for the whole callback, +/// and the adapter deep-copies them (via `__rt_str_persist`) while boxing, so they +/// need not outlive the call. Offsets (asserted on the codegen side): tag@0, i@8, +/// f@16, ptr@24, len@32. +#[repr(C)] +struct ElephcVal { + /// 0 = NULL, 1 = INT, 2 = FLOAT, 3 = TEXT, 4 = BLOB. + tag: i64, + /// Integer payload (tag 1). + i: i64, + /// Float payload (tag 2). + f: f64, + /// TEXT/BLOB byte pointer (tags 3/4), aliasing the `sqlite3_value` buffer. + ptr: *const u8, + /// TEXT/BLOB byte length (tags 3/4). + len: i64, +} + +/// The scalar user function's return value crossing back from the codegen adapter +/// into `x_scalar`. `#[repr(C)]` POD; offsets tag@0, i@8, f@16. String/blob results +/// do NOT cross as raw pointers: the adapter copies the bytes into the bridge's +/// result stash (`elephc_pdo_udf_stash_bytes`) before releasing its Mixed and sets +/// `tag` to TEXT/BLOB, and `x_scalar` reads the stash. `tag = -1` signals that the +/// callback threw (the adapter's firewall caught it) so `x_scalar` raises a SQL error. +#[repr(C)] +struct ElephcResult { + /// -1 = ERROR (callback threw), 0 = NULL, 1 = INT, 2 = FLOAT, 3 = TEXT, + /// 4 = BLOB, 5 = BOOL (0/1 in `i`). + tag: i64, + /// Integer / bool payload (tags 1/5). + i: i64, + /// Float payload (tag 2). + f: f64, +} + +/// The C-ABI adapter that re-enters a compiled-PHP scalar user function. Emitted by +/// codegen as `__rt_pdo_call_scalar`; the bridge only stores and calls its address. +/// It boxes each `ElephcVal` into a Mixed argument, invokes the callable descriptor's +/// uniform invoker, and writes the return into `*out` (stashing string/blob bytes in +/// the bridge first). A thrown callback is caught by its firewall and reported as +/// `out.tag = -1`. +type ScalarAdapter = unsafe extern "C" fn( + descriptor: *mut c_void, + argv: *const ElephcVal, + argc: i64, + out: *mut ElephcResult, +); + +/// Converts one nullable, NUL-terminated SQLite authorizer argument to the +/// byte-counted value record consumed by the shared scalar callback adapter. +/// +/// # Safety +/// A non-null `value` must point to a live NUL-terminated SQLite string for the +/// duration of the current authorizer callback. +unsafe fn decode_nullable_cstr(value: *const c_char) -> ElephcVal { + if value.is_null() { + return ElephcVal { + tag: 0, + i: 0, + f: 0.0, + ptr: ptr::null(), + len: 0, + }; + } + let bytes = CStr::from_ptr(value).to_bytes(); + ElephcVal { + tag: 3, + i: 0, + f: 0.0, + ptr: bytes.as_ptr(), + len: bytes.len() as i64, + } +} + +/// SQLite authorizer dispatcher. It forwards the action code and four nullable +/// context strings to the compiled-PHP callable and accepts only the three integer +/// decisions SQLite defines (`OK`, `DENY`, and `IGNORE`). Exceptions and invalid +/// return types/values fail closed with `SQLITE_DENY`. +/// +/// # Safety +/// `p_arg` must be a live `Box` installed by `set_authorizer`; every +/// non-null string pointer is owned by SQLite and valid for this callback. +unsafe extern "C" fn x_authorizer( + p_arg: *mut c_void, + action: c_int, + arg1: *const c_char, + arg2: *const c_char, + arg3: *const c_char, + arg4: *const c_char, +) -> c_int { + if p_arg.is_null() { + return ffi::SQLITE_OK; + } + let reg = &*(p_arg as *const AuthorizerReg); + let values = [ + ElephcVal { + tag: 1, + i: action as i64, + f: 0.0, + ptr: ptr::null(), + len: 0, + }, + decode_nullable_cstr(arg1), + decode_nullable_cstr(arg2), + decode_nullable_cstr(arg3), + decode_nullable_cstr(arg4), + ]; + let adapter: ScalarAdapter = std::mem::transmute(reg.adapter); + let mut out = ElephcResult { + tag: 0, + i: 0, + f: 0.0, + }; + udf_result_stash_clear(); + adapter( + reg.descriptor, + values.as_ptr(), + values.len() as i64, + &mut out, + ); + let error = match out.tag { + 1 if matches!(out.i, 0..=2) => 0, + 1 => 1, + -1 => 2, + 0 => 10, + 2 => 11, + 3 | 4 => 12, + 5 => 13, + 6 => 14, + 7 => 15, + _ => 15, + }; + reg.error.store(error, Ordering::Release); + if error == 0 { + out.i as c_int + } else { + ffi::SQLITE_DENY + } +} + +thread_local! { + /// Per-thread staging buffer for a scalar/aggregate UDF's string or blob return. + /// The codegen adapter copies the compiled-PHP string bytes here (they live in the + /// program's arena and vanish when the adapter returns) via `elephc_pdo_udf_stash_bytes`; + /// `x_scalar` then hands them to SQLite with `SQLITE_TRANSIENT`. Thread-local because + /// the adapter runs synchronously on the query's own thread inside the shim. + static UDF_RESULT_STASH: std::cell::RefCell<(Vec, bool)> = + const { std::cell::RefCell::new((Vec::new(), false)) }; +} + +/// Stages a compiled-PHP UDF string/blob return into the per-thread result stash so +/// `x_scalar` can copy it into SQLite after the adapter releases its Mixed. `is_blob` +/// selects `sqlite3_result_blob` over `_text`. A null pointer or non-positive length +/// stages an empty value, and so does a caught panic — `x_scalar` then hands SQLite an +/// empty result rather than aborting the process. +/// +/// [`ffi_guard`] wraps this like every other `#[no_mangle]` body (F-QUAL-02): it is the +/// one bridge entry point outside `lib.rs`, and it is reached from a compiled-PHP UDF +/// callback running inside SQLite's own call stack — so an unwind out of it would cross +/// TWO `extern "C"` frames (this one and SQLite's `xFunc`) and abort. +/// +/// # Safety +/// `ptr` must reference `len` readable bytes for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn elephc_pdo_udf_stash_bytes(ptr: *const u8, len: i64, is_blob: i64) { + ffi_guard((), || { + let bytes = if ptr.is_null() || len <= 0 { + Vec::new() + } else { + std::slice::from_raw_parts(ptr, len as usize).to_vec() + }; + UDF_RESULT_STASH.with(|stash| *stash.borrow_mut() = (bytes, is_blob != 0)); + }) +} + +/// Takes and clears the staged UDF string/blob return `(bytes, is_blob)`. +fn udf_result_stash_take() -> (Vec, bool) { + UDF_RESULT_STASH.with(|stash| std::mem::take(&mut *stash.borrow_mut())) +} + +/// Clears any stale staged UDF result before invoking a callback. +fn udf_result_stash_clear() { + UDF_RESULT_STASH.with(|stash| { + let mut stash = stash.borrow_mut(); + stash.0.clear(); + stash.1 = false; + }); +} + +/// Decodes one `sqlite3_value` into an `ElephcVal`, mirroring the statement fetch +/// path's byte-counted read (`sqlite3_value_blob` + `_bytes`) so TEXT/BLOB arguments +/// with embedded NUL bytes round-trip exactly. +/// +/// # Safety +/// `v` must be a live `sqlite3_value` valid for the current callback. +unsafe fn decode_value(v: *mut ffi::sqlite3_value) -> ElephcVal { + match ffi::sqlite3_value_type(v) { + 1 => ElephcVal { + tag: 1, + i: ffi::sqlite3_value_int64(v), + f: 0.0, + ptr: std::ptr::null(), + len: 0, + }, + 2 => ElephcVal { + tag: 2, + i: 0, + f: ffi::sqlite3_value_double(v), + ptr: std::ptr::null(), + len: 0, + }, + code @ (3 | 4) => { + let ptr = ffi::sqlite3_value_blob(v) as *const u8; + let len = ffi::sqlite3_value_bytes(v).max(0) as i64; + ElephcVal { + tag: code as i64, + i: 0, + f: 0.0, + ptr, + len, + } + } + _ => ElephcVal { + tag: 0, + i: 0, + f: 0.0, + ptr: std::ptr::null(), + len: 0, + }, + } +} + +/// Writes an `ElephcResult` into the SQLite call context via the `sqlite3_result_*` +/// family. String/blob results are copied out of the per-thread stash with +/// `SQLITE_TRANSIENT` (SQLite owns its own copy); a `-1` tag raises a SQL error. +/// +/// # Safety +/// `ctx` must be the live `sqlite3_context` for the current callback. +unsafe fn dispatch_scalar_result(ctx: *mut ffi::sqlite3_context, out: &ElephcResult) { + match out.tag { + -1 => { + let msg = c"PDO user function callback raised an exception"; + ffi::sqlite3_result_error(ctx, msg.as_ptr(), -1); + } + 1 | 5 => ffi::sqlite3_result_int64(ctx, out.i), + 2 => ffi::sqlite3_result_double(ctx, out.f), + 3 | 4 => { + let (bytes, is_blob) = udf_result_stash_take(); + if is_blob || out.tag == 4 { + ffi::sqlite3_result_blob( + ctx, + bytes.as_ptr() as *const c_void, + bytes.len() as c_int, + ffi::SQLITE_TRANSIENT(), + ); + } else { + ffi::sqlite3_result_text( + ctx, + bytes.as_ptr() as *const c_char, + bytes.len() as c_int, + ffi::SQLITE_TRANSIENT(), + ); + } + } + 6 | 7 => { + let msg = c"PDO user function callback returned an unsupported type"; + ffi::sqlite3_result_error(ctx, msg.as_ptr(), -1); + } + _ => ffi::sqlite3_result_null(ctx), + } +} + +/// SQLite scalar user-function dispatcher (`xFunc`). Unlike `x_compare`, a scalar +/// callback receives no `pApp` argument, so the `UdfReg` is recovered through +/// `sqlite3_user_data`. Each argument is decoded into an `ElephcVal`, the codegen +/// adapter re-enters the compiled-PHP callable, and its `ElephcResult` is written +/// back through the `sqlite3_result_*` family. +/// +/// # Safety +/// `ctx`/`argv` are the live SQLite call context and argument vector; the registered +/// `pApp` is a live `Box` pointer. +unsafe extern "C" fn x_scalar( + ctx: *mut ffi::sqlite3_context, + argc: c_int, + argv: *mut *mut ffi::sqlite3_value, +) { + let p_arg = ffi::sqlite3_user_data(ctx); + if p_arg.is_null() { + ffi::sqlite3_result_null(ctx); + return; + } + let reg = &*(p_arg as *const UdfReg); + let mut vals: Vec = Vec::with_capacity(argc.max(0) as usize); + for idx in 0..argc { + vals.push(decode_value(*argv.offset(idx as isize))); + } + let adapter: ScalarAdapter = std::mem::transmute(reg.adapter); + let mut out = ElephcResult { + tag: 0, + i: 0, + f: 0.0, + }; + udf_result_stash_clear(); + adapter(reg.descriptor, vals.as_ptr(), vals.len() as i64, &mut out); + dispatch_scalar_result(ctx, &out); +} + +/// A registered SQLite aggregate: the step and finalize callables each as a +/// (descriptor, adapter) pair. Boxed and handed to SQLite as the registration's +/// `pApp`, recovered by both `x_agg_step` and `x_agg_final` via +/// `sqlite3_user_data`, and freed by `x_destroy_agg`. Distinct from `UdfReg` +/// (which holds a single pair): an aggregate needs both callables, so a +/// same-`pApp` widening would mis-size the `Box` free — hence a separate struct +/// and a separate destroy. +#[repr(C)] +struct AggReg { + /// The step callable's compiled-PHP descriptor pointer. + step_descriptor: *mut c_void, + /// The codegen step adapter entry (`__rt_pdo_call_agg_step`). + step_adapter: *const c_void, + /// The finalize callable's compiled-PHP descriptor pointer. + final_descriptor: *mut c_void, + /// The codegen finalize adapter entry (`__rt_pdo_call_agg_final`). + final_adapter: *const c_void, +} + +/// The per-group aggregate state SQLite keeps in `sqlite3_aggregate_context`. A +/// `#[repr(C)]` POD so the fixed 16-byte block is shared unambiguously across step +/// calls and the final call within one aggregation group. `row_count` is the +/// running number of `xStep` invocations so far (the `$rownumber` passed to the +/// callbacks); `accumulator` is the boxed-Mixed PHP value the last step returned +/// (null before the first step). SQLite owns and auto-frees this 16-byte block when +/// the aggregation concludes; the bridge/adapters own the pointed-to accumulator box +/// (which lives in the compiled program's heap) and release it inside `x_agg_final`. +#[repr(C)] +struct AggCtx { + /// Running `xStep` count within the group (0 before the first step). + row_count: i64, + /// The boxed-Mixed accumulator the last step returned (null = none yet). + accumulator: *mut c_void, +} + +/// The C-ABI adapter that re-enters a compiled-PHP aggregate step callback. Emitted +/// by codegen as `__rt_pdo_call_agg_step`; the bridge only stores and calls its +/// address. It boxes `[accumulator, rownumber, ...rowValues]` as the invoker's +/// arguments, invokes the step callable, and returns the OWNED boxed-Mixed new +/// accumulator (the bridge stores it back into `AggCtx.accumulator`). On a thrown +/// callback the adapter's firewall catches the longjmp, preserves the accumulator +/// (so `x_agg_final` still frees it), sets `*threw = 1`, and returns null. +type StepAdapter = unsafe extern "C" fn( + descriptor: *mut c_void, + accumulator: *mut c_void, + rownumber: i64, + argv: *const ElephcVal, + argc: i64, + threw: *mut i64, +) -> *mut c_void; + +/// The C-ABI adapter that re-enters a compiled-PHP aggregate finalize callback. +/// Emitted by codegen as `__rt_pdo_call_agg_final`; the bridge only stores and calls +/// its address. It boxes `[accumulator, rownumber]`, invokes the finalize callable, +/// writes the aggregate result into `*out` (an `ElephcResult`, decoded exactly like +/// the scalar path), and — since finalize is terminal for the group — releases the +/// accumulator box. A thrown callback is reported as `out.tag = -1`. +type FinalAdapter = unsafe extern "C" fn( + descriptor: *mut c_void, + accumulator: *mut c_void, + rownumber: i64, + out: *mut ElephcResult, +); + +/// SQLite aggregate step dispatcher (`xStep`). Recovers the `AggReg` via +/// `sqlite3_user_data` and the per-group `AggCtx` via `sqlite3_aggregate_context` +/// (16 bytes, zeroed on the first step of a group). Decodes the row arguments, calls +/// the codegen step adapter with the current accumulator + row number, and stores the +/// new accumulator back. A thrown step callback (`threw != 0`) surfaces a SQL error; +/// SQLite then aborts the aggregation but still runs `xFinal`, which frees the +/// accumulator the adapter preserved. +/// +/// # Safety +/// `ctx`/`argv` are the live SQLite call context and argument vector; the registered +/// `pApp` is a live `Box` pointer. +unsafe extern "C" fn x_agg_step( + ctx: *mut ffi::sqlite3_context, + argc: c_int, + argv: *mut *mut ffi::sqlite3_value, +) { + let p_arg = ffi::sqlite3_user_data(ctx); + if p_arg.is_null() { + return; + } + let reg = &*(p_arg as *const AggReg); + let slot = + ffi::sqlite3_aggregate_context(ctx, std::mem::size_of::() as c_int) as *mut AggCtx; + if slot.is_null() { + // Out of memory: the group cannot be aggregated. SQLite reports the OOM. + return; + } + let mut vals: Vec = Vec::with_capacity(argc.max(0) as usize); + for idx in 0..argc { + vals.push(decode_value(*argv.offset(idx as isize))); + } + // PHP (`sqlite_driver.c`: `ZVAL_LONG(&zargs[1], ++agg_context->row)`) pre-increments + // the shared row counter before passing it to the callback, so `$rownumber` runs + // 1..N across the group's steps (never 0). Increment first, then pass. + (*slot).row_count += 1; + let adapter: StepAdapter = std::mem::transmute(reg.step_adapter); + let mut threw: i64 = 0; + let new_acc = adapter( + reg.step_descriptor, + (*slot).accumulator, + (*slot).row_count, + vals.as_ptr(), + vals.len() as i64, + &mut threw, + ); + if threw != 0 { + // The adapter preserved the accumulator (did not release the slot's ref) and + // returned null. Surface a SQL error; xFinal still runs and frees it. + let msg = c"PDO aggregate step callback raised an exception"; + ffi::sqlite3_result_error(ctx, msg.as_ptr(), -1); + } else { + (*slot).accumulator = new_acc; + } +} + +/// SQLite aggregate finalize dispatcher (`xFinal`). Recovers the `AggReg` and reads +/// the per-group `AggCtx` with `sqlite3_aggregate_context(ctx, 0)` — passing 0 so an +/// empty group (no `xStep` ever ran) returns a NULL slot rather than allocating, +/// which the dispatcher treats as `{row_count: 0, accumulator: null}` (PHP null +/// context) before pre-incrementing the row number it hands to the adapter (see +/// below). Calls the codegen finalize adapter to produce the result and release the +/// accumulator, writes it through `dispatch_scalar_result`, then nulls the slot so no +/// freed pointer dangles before SQLite frees the block. +/// +/// # Safety +/// `ctx` is the live SQLite call context; the registered `pApp` is a live +/// `Box` pointer. +unsafe extern "C" fn x_agg_final(ctx: *mut ffi::sqlite3_context) { + let p_arg = ffi::sqlite3_user_data(ctx); + if p_arg.is_null() { + ffi::sqlite3_result_null(ctx); + return; + } + let reg = &*(p_arg as *const AggReg); + // nBytes 0: do NOT allocate for an empty group; a NULL slot means "never stepped". + let slot = ffi::sqlite3_aggregate_context(ctx, 0) as *mut AggCtx; + // PHP pre-increments the SAME shared row counter for the finalize call too + // (`++agg_context->row`), so finalize sees one past the last step's rownumber + // (N+1 for an N-step group), or 1 for an empty group (the counter starts at 0 + // and is pre-incremented even though xStep never ran). + let (accumulator, row_count) = if slot.is_null() { + (ptr::null_mut(), 1i64) + } else { + ((*slot).accumulator, (*slot).row_count + 1) + }; + let adapter: FinalAdapter = std::mem::transmute(reg.final_adapter); + let mut out = ElephcResult { + tag: 0, + i: 0, + f: 0.0, + }; + udf_result_stash_clear(); + adapter(reg.final_descriptor, accumulator, row_count, &mut out); + dispatch_scalar_result(ctx, &out); + // The adapter released the accumulator box; null the slot so the now-freed + // pointer never dangles (SQLite frees the 16-byte block right after this returns). + if !slot.is_null() { + (*slot).accumulator = ptr::null_mut(); + } +} + +/// Frees a `Box` when SQLite deletes an aggregate registration. Registered as +/// every aggregate's `xDestroy`. Distinct from `x_destroy` (which frees a `UdfReg`): +/// `AggReg` is a different, larger type, so freeing it through the wrong `Box` type +/// would pass a mismatched `Layout` to the allocator. +/// +/// # Safety +/// `p_arg` must be a pointer produced by `Box::into_raw` for an `AggReg`. +unsafe extern "C" fn x_destroy_agg(p_arg: *mut c_void) { + if !p_arg.is_null() { + drop(Box::from_raw(p_arg as *mut AggReg)); } } impl SqliteStmt { + /// Returns SQLite's source table name for result column `i`, or an empty + /// string for expressions and out-of-range columns. + pub fn column_table_name(&self, i: i64) -> String { + if i < 0 { + return String::new(); + } + unsafe { + let value = ffi::sqlite3_column_table_name(self.ptr, i as c_int); + if value.is_null() { + String::new() + } else { + CStr::from_ptr(value).to_string_lossy().into_owned() + } + } + } + /// Resolves a named placeholder to its 1-based bind index, trying the /// `:name`, `@name`, `$name` prefixes and the bare name. Returns `0` when no /// placeholder matches. @@ -175,16 +1565,33 @@ impl SqliteStmt { (rc == ffi::SQLITE_OK) as i64 } - /// Binds a text value (copied via `SQLITE_TRANSIENT`) to placeholder `idx`. - /// A null pointer binds SQL NULL. Returns `1`/`0`. + /// Binds a text value (copied via `SQLITE_TRANSIENT`) to placeholder `idx`, + /// using the caller-supplied `len` (the value's true byte length) rather than + /// SQLite's strlen-based `-1` sentinel, so a value with an embedded NUL byte + /// binds in full instead of truncating at the first NUL. A null pointer binds + /// SQL NULL. A non-positive or `c_int`-overflowing `len` is treated as a + /// zero-length string rather than being cast as-is, matching `bind_blob`'s + /// clamp. Returns `1`/`0`. /// /// # Safety - /// `val`, when non-null, must point to a NUL-terminated string valid for the call. - pub unsafe fn bind_text(&self, idx: i64, val: *const c_char) -> i64 { + /// `val`, when non-null, must point to at least `len` readable bytes valid for + /// the call. + pub unsafe fn bind_text(&self, idx: i64, val: *const c_char, len: i64) -> i64 { if val.is_null() { return (ffi::sqlite3_bind_null(self.ptr, idx as c_int) == ffi::SQLITE_OK) as i64; } - let rc = ffi::sqlite3_bind_text(self.ptr, idx as c_int, val, -1, ffi::SQLITE_TRANSIENT()); + let safe_len = if len <= 0 || len > c_int::MAX as i64 { + 0 + } else { + len as c_int + }; + let rc = ffi::sqlite3_bind_text( + self.ptr, + idx as c_int, + val, + safe_len, + ffi::SQLITE_TRANSIENT(), + ); (rc == ffi::SQLITE_OK) as i64 } @@ -194,6 +1601,35 @@ impl SqliteStmt { (rc == ffi::SQLITE_OK) as i64 } + /// Binds raw bytes (copied via `SQLITE_TRANSIENT`) to placeholder `idx`, + /// preserving embedded NUL bytes that `bind_text`'s NUL-terminated string + /// path cannot. A null pointer binds SQL NULL. A non-positive or + /// `c_int`-overflowing `len` is treated as a zero-length blob rather than + /// being cast as-is, which would silently wrap/truncate when handed to + /// `sqlite3_bind_blob`'s `c_int` length parameter. Returns `1`/`0`. + /// + /// # Safety + /// `ptr`, when non-null, must point to at least `len` readable bytes valid for + /// the call. + pub unsafe fn bind_blob(&self, idx: i64, ptr: *const c_char, len: i64) -> i64 { + if ptr.is_null() { + return (ffi::sqlite3_bind_null(self.ptr, idx as c_int) == ffi::SQLITE_OK) as i64; + } + let safe_len = if len <= 0 || len > c_int::MAX as i64 { + 0 + } else { + len as c_int + }; + let rc = ffi::sqlite3_bind_blob( + self.ptr, + idx as c_int, + ptr as *const std::os::raw::c_void, + safe_len, + ffi::SQLITE_TRANSIENT(), + ); + (rc == ffi::SQLITE_OK) as i64 + } + /// Resets the statement, keeping its parameter bindings. Returns `1`. pub fn reset(&self) -> i64 { unsafe { ffi::sqlite3_reset(self.ptr) }; @@ -240,6 +1676,19 @@ impl SqliteStmt { unsafe { ffi::sqlite3_column_type(self.ptr, i as c_int) as i64 } } + /// Returns the declared type of result column `i` (`sqlite3_column_decltype`), + /// e.g. "INTEGER" or "TEXT", or an empty string for an expression column with no + /// declared type. Feeds `PDOStatement::getColumnMeta`'s native_type. + pub fn column_decltype(&self, i: i64) -> String { + unsafe { + let p = ffi::sqlite3_column_decltype(self.ptr, i as c_int); + if p.is_null() { + return String::new(); + } + CStr::from_ptr(p).to_string_lossy().into_owned() + } + } + /// Returns the current row's column `i` (0-based) as an integer. pub fn column_int(&self, i: i64) -> i64 { unsafe { ffi::sqlite3_column_int64(self.ptr, i as c_int) } @@ -250,11 +1699,6 @@ impl SqliteStmt { unsafe { ffi::sqlite3_column_double(self.ptr, i as c_int) } } - /// Returns the current row's column `i` (0-based) text representation. - pub fn column_text(&self, i: i64) -> String { - String::from_utf8_lossy(&self.column_data(i)).into_owned() - } - /// Returns the current row's column `i` (0-based) as raw SQLite bytes. /// This uses SQLite's byte-counted column API, so embedded NUL bytes are /// preserved for BLOBs and text values alike. @@ -271,8 +1715,56 @@ impl SqliteStmt { } } - /// Finalizes the statement. + /// Finalizes the statement, releasing the native handle. Idempotent: a second + /// call — or the `Drop` net — is a no-op. `sqlite3_finalize` destroys the + /// statement whatever it returns (its result code reports the *last step's* + /// error, not a failure to free), so one call always releases. pub fn finalize(&self) { + if self.released.replace(true) || self.ptr.is_null() { + return; + } unsafe { ffi::sqlite3_finalize(self.ptr) }; } + + /// Returns SQLite's primary result code for the statement's connection's last + /// operation (SQLite tracks error state per-connection, not per-statement). + pub fn errcode(&self) -> i64 { + unsafe { ffi::sqlite3_errcode(self.db) as i64 } + } + + /// Returns the statement's connection's current error message (see `errcode`). + pub fn errmsg(&self) -> String { + unsafe { read_errmsg(self.db) } + } + + /// Returns the 5-char SQLSTATE for the statement's last operation. + pub fn sqlstate(&self) -> String { + sqlite_sqlstate(unsafe { ffi::sqlite3_errcode(self.db) }).to_string() + } + + /// Returns `1` if the statement makes no direct changes to the content of + /// the database file (`sqlite3_stmt_readonly`), else `0`. Backs + /// `PDOStatement::getAttribute(Pdo\Sqlite::ATTR_READONLY_STATEMENT)` (P2-16) + /// as a live read rather than a stored value. + pub fn readonly(&self) -> i64 { + (unsafe { ffi::sqlite3_stmt_readonly(self.ptr) } != 0) as i64 + } + + /// Returns whether SQLite has stepped this statement and not yet reset or finalized it. + pub fn busy(&self) -> i64 { + unsafe { (ffi::sqlite3_stmt_busy(self.ptr) != 0) as i64 } + } + + /// Returns SQLite's current explain mode for this statement. + pub fn explain_mode(&self) -> i64 { + unsafe { ffi::sqlite3_stmt_isexplain(self.ptr) as i64 } + } + + /// Selects SQLite's prepared, EXPLAIN, or EXPLAIN QUERY PLAN mode. + pub fn set_explain_mode(&self, mode: i64) -> i64 { + if !(0..=2).contains(&mode) { + return 0; + } + unsafe { (ffi::sqlite3_stmt_explain(self.ptr, mode as c_int) == ffi::SQLITE_OK) as i64 } + } } diff --git a/docs/internals/builtins/_internal/__elephc_callable_ptr.md b/docs/internals/builtins/_internal/__elephc_callable_ptr.md new file mode 100644 index 0000000000..37e29bd430 --- /dev/null +++ b/docs/internals/builtins/_internal/__elephc_callable_ptr.md @@ -0,0 +1,53 @@ +--- +title: "__elephc_callable_ptr() — internals" +description: "Compiler internals for __elephc_callable_ptr(): lowering path, type checks, and runtime helpers." +sidebar: + order: 461 +--- + +## `__elephc_callable_ptr()` — internals + +## Where it lives + +- **Signature**: [`src/builtins/pointers/elephc_callable_ptr.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/elephc_callable_ptr.rs) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) +- **Function symbol**: `lower_registry_call()` + + +### Lowering notes + +- Uses the `eir_primitive` strategy from the single-source builtin descriptor. +- Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`. + +## Semantic descriptor + +- **Target strategy**: `eir_primitive` +- **Validation**: `checker_hook` +- **Result type source**: `checked` +- **Result ownership**: `non_heap` +- **Effects**: `static (0 declared effects)` +- **Requirements**: `static (0 requirements)` +- **Callable policy**: `static_only` +- **Target support**: `macos-aarch64`, `linux-aarch64`, `linux-x86_64` + +## EIR and runtime boundary + +- **Typed EIR target**: descriptor-emitted EIR primitives or graph; no opaque builtin call remains. + +## Signature summary + +```php +function __elephc_callable_ptr(mixed $value): mixed +``` + +## What the type checker enforces + +- **Arity**: takes exactly 1 argument. + +## Eval interpreter (magician) + +_Not callable from eval'd code — the magician interpreter has no entry for this builtin._ + +## Cross-references + +- _No user-facing reference — this is a compiler internal helper._ diff --git a/docs/internals/builtins/_internal/__elephc_class_has_constructor.md b/docs/internals/builtins/_internal/__elephc_class_has_constructor.md new file mode 100644 index 0000000000..9c6d130282 --- /dev/null +++ b/docs/internals/builtins/_internal/__elephc_class_has_constructor.md @@ -0,0 +1,53 @@ +--- +title: "__elephc_class_has_constructor() — internals" +description: "Compiler internals for __elephc_class_has_constructor(): lowering path, type checks, and runtime helpers." +sidebar: + order: 462 +--- + +## `__elephc_class_has_constructor()` — internals + +## Where it lives + +- **Signature**: [`src/builtins/system/__elephc_class_has_constructor.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/__elephc_class_has_constructor.rs) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) +- **Function symbol**: `lower_registry_call()` + + +### Lowering notes + +- Uses the `eir_primitive` strategy from the single-source builtin descriptor. +- Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`. + +## Semantic descriptor + +- **Target strategy**: `eir_primitive` +- **Validation**: `signature` +- **Result type source**: `declared` +- **Result ownership**: `non_heap` +- **Effects**: `static (0 declared effects)` +- **Requirements**: `static (0 requirements)` +- **Callable policy**: `static_only` +- **Target support**: `macos-aarch64`, `linux-aarch64`, `linux-x86_64` + +## EIR and runtime boundary + +- **Typed EIR target**: descriptor-emitted EIR primitives or graph; no opaque builtin call remains. + +## Signature summary + +```php +function __elephc_class_has_constructor(string $class): bool +``` + +## What the type checker enforces + +- **Arity**: takes exactly 1 argument. + +## Eval interpreter (magician) + +_Not callable from eval'd code — the magician interpreter has no entry for this builtin._ + +## Cross-references + +- _No user-facing reference — this is a compiler internal helper._ diff --git a/docs/internals/builtins/_internal/__elephc_gmmktime_raw.md b/docs/internals/builtins/_internal/__elephc_gmmktime_raw.md index d98af24897..fa15db0775 100644 --- a/docs/internals/builtins/_internal/__elephc_gmmktime_raw.md +++ b/docs/internals/builtins/_internal/__elephc_gmmktime_raw.md @@ -2,7 +2,7 @@ title: "__elephc_gmmktime_raw() — internals" description: "Compiler internals for __elephc_gmmktime_raw(): lowering path, type checks, and runtime helpers." sidebar: - order: 461 + order: 463 --- ## `__elephc_gmmktime_raw()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/__elephc_gmmktime_raw.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/__elephc_gmmktime_raw.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_hash_ctx_copy.md b/docs/internals/builtins/_internal/__elephc_hash_ctx_copy.md index ae42b7a89a..030cd36013 100644 --- a/docs/internals/builtins/_internal/__elephc_hash_ctx_copy.md +++ b/docs/internals/builtins/_internal/__elephc_hash_ctx_copy.md @@ -2,7 +2,7 @@ title: "__elephc_hash_ctx_copy() — internals" description: "Compiler internals for __elephc_hash_ctx_copy(): lowering path, type checks, and runtime helpers." sidebar: - order: 462 + order: 464 --- ## `__elephc_hash_ctx_copy()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/__elephc_hash_ctx_copy.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/__elephc_hash_ctx_copy.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_hash_ctx_final.md b/docs/internals/builtins/_internal/__elephc_hash_ctx_final.md index e0d49e11b5..2cfb51f1f1 100644 --- a/docs/internals/builtins/_internal/__elephc_hash_ctx_final.md +++ b/docs/internals/builtins/_internal/__elephc_hash_ctx_final.md @@ -2,7 +2,7 @@ title: "__elephc_hash_ctx_final() — internals" description: "Compiler internals for __elephc_hash_ctx_final(): lowering path, type checks, and runtime helpers." sidebar: - order: 463 + order: 465 --- ## `__elephc_hash_ctx_final()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/__elephc_hash_ctx_final.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/__elephc_hash_ctx_final.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_hash_ctx_init.md b/docs/internals/builtins/_internal/__elephc_hash_ctx_init.md index 1636c037e4..52f9d784a5 100644 --- a/docs/internals/builtins/_internal/__elephc_hash_ctx_init.md +++ b/docs/internals/builtins/_internal/__elephc_hash_ctx_init.md @@ -2,7 +2,7 @@ title: "__elephc_hash_ctx_init() — internals" description: "Compiler internals for __elephc_hash_ctx_init(): lowering path, type checks, and runtime helpers." sidebar: - order: 464 + order: 466 --- ## `__elephc_hash_ctx_init()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/__elephc_hash_ctx_init.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/__elephc_hash_ctx_init.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_hash_ctx_update.md b/docs/internals/builtins/_internal/__elephc_hash_ctx_update.md index 550434f1f2..b88d53cac4 100644 --- a/docs/internals/builtins/_internal/__elephc_hash_ctx_update.md +++ b/docs/internals/builtins/_internal/__elephc_hash_ctx_update.md @@ -2,7 +2,7 @@ title: "__elephc_hash_ctx_update() — internals" description: "Compiler internals for __elephc_hash_ctx_update(): lowering path, type checks, and runtime helpers." sidebar: - order: 465 + order: 467 --- ## `__elephc_hash_ctx_update()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/__elephc_hash_ctx_update.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/__elephc_hash_ctx_update.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_initialize_pdo_statement.md b/docs/internals/builtins/_internal/__elephc_initialize_pdo_statement.md new file mode 100644 index 0000000000..803376eb59 --- /dev/null +++ b/docs/internals/builtins/_internal/__elephc_initialize_pdo_statement.md @@ -0,0 +1,53 @@ +--- +title: "__elephc_initialize_pdo_statement() — internals" +description: "Compiler internals for __elephc_initialize_pdo_statement(): lowering path, type checks, and runtime helpers." +sidebar: + order: 468 +--- + +## `__elephc_initialize_pdo_statement()` — internals + +## Where it lives + +- **Signature**: [`src/builtins/system/__elephc_initialize_pdo_statement.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/__elephc_initialize_pdo_statement.rs) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) +- **Function symbol**: `lower_registry_call()` + + +### Lowering notes + +- Uses the `eir_primitive` strategy from the single-source builtin descriptor. +- Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`. + +## Semantic descriptor + +- **Target strategy**: `eir_primitive` +- **Validation**: `signature` +- **Result type source**: `declared` +- **Result ownership**: `non_heap` +- **Effects**: `static (17 declared effects)` +- **Requirements**: `static (0 requirements)` +- **Callable policy**: `static_only` +- **Target support**: `macos-aarch64`, `linux-aarch64`, `linux-x86_64` + +## EIR and runtime boundary + +- **Typed EIR target**: descriptor-emitted EIR primitives or graph; no opaque builtin call remains. + +## Signature summary + +```php +function __elephc_initialize_pdo_statement(mixed $statement, int $handle, int $connection, int $errorMode, string $query): void +``` + +## What the type checker enforces + +- **Arity**: takes exactly 5 arguments. + +## Eval interpreter (magician) + +_Not callable from eval'd code — the magician interpreter has no entry for this builtin._ + +## Cross-references + +- _No user-facing reference — this is a compiler internal helper._ diff --git a/docs/internals/builtins/_internal/__elephc_invoke_pdo_statement_constructor.md b/docs/internals/builtins/_internal/__elephc_invoke_pdo_statement_constructor.md new file mode 100644 index 0000000000..1dac19a3a5 --- /dev/null +++ b/docs/internals/builtins/_internal/__elephc_invoke_pdo_statement_constructor.md @@ -0,0 +1,53 @@ +--- +title: "__elephc_invoke_pdo_statement_constructor() — internals" +description: "Compiler internals for __elephc_invoke_pdo_statement_constructor(): lowering path, type checks, and runtime helpers." +sidebar: + order: 469 +--- + +## `__elephc_invoke_pdo_statement_constructor()` — internals + +## Where it lives + +- **Signature**: [`src/builtins/system/__elephc_invoke_pdo_statement_constructor.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/__elephc_invoke_pdo_statement_constructor.rs) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) +- **Function symbol**: `lower_registry_call()` + + +### Lowering notes + +- Uses the `eir_primitive` strategy from the single-source builtin descriptor. +- Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`. + +## Semantic descriptor + +- **Target strategy**: `eir_primitive` +- **Validation**: `signature` +- **Result type source**: `declared` +- **Result ownership**: `non_heap` +- **Effects**: `static (17 declared effects)` +- **Requirements**: `static (0 requirements)` +- **Callable policy**: `static_only` +- **Target support**: `macos-aarch64`, `linux-aarch64`, `linux-x86_64` + +## EIR and runtime boundary + +- **Typed EIR target**: descriptor-emitted EIR primitives or graph; no opaque builtin call remains. + +## Signature summary + +```php +function __elephc_invoke_pdo_statement_constructor(string $class, mixed $statement, mixed $arguments): void +``` + +## What the type checker enforces + +- **Arity**: takes exactly 3 arguments. + +## Eval interpreter (magician) + +_Not callable from eval'd code — the magician interpreter has no entry for this builtin._ + +## Cross-references + +- _No user-facing reference — this is a compiler internal helper._ diff --git a/docs/internals/builtins/_internal/__elephc_mktime_raw.md b/docs/internals/builtins/_internal/__elephc_mktime_raw.md index f255690ef9..bf24458d57 100644 --- a/docs/internals/builtins/_internal/__elephc_mktime_raw.md +++ b/docs/internals/builtins/_internal/__elephc_mktime_raw.md @@ -2,7 +2,7 @@ title: "__elephc_mktime_raw() — internals" description: "Compiler internals for __elephc_mktime_raw(): lowering path, type checks, and runtime helpers." sidebar: - order: 466 + order: 470 --- ## `__elephc_mktime_raw()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/__elephc_mktime_raw.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/__elephc_mktime_raw.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_new_without_constructor.md b/docs/internals/builtins/_internal/__elephc_new_without_constructor.md new file mode 100644 index 0000000000..deb413694f --- /dev/null +++ b/docs/internals/builtins/_internal/__elephc_new_without_constructor.md @@ -0,0 +1,53 @@ +--- +title: "__elephc_new_without_constructor() — internals" +description: "Compiler internals for __elephc_new_without_constructor(): lowering path, type checks, and runtime helpers." +sidebar: + order: 471 +--- + +## `__elephc_new_without_constructor()` — internals + +## Where it lives + +- **Signature**: [`src/builtins/system/__elephc_new_without_constructor.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/__elephc_new_without_constructor.rs) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) +- **Function symbol**: `lower_registry_call()` + + +### Lowering notes + +- Uses the `eir_primitive` strategy from the single-source builtin descriptor. +- Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`. + +## Semantic descriptor + +- **Target strategy**: `eir_primitive` +- **Validation**: `signature` +- **Result type source**: `declared` +- **Result ownership**: `fresh` +- **Effects**: `static (3 declared effects)` +- **Requirements**: `static (0 requirements)` +- **Callable policy**: `static_only` +- **Target support**: `macos-aarch64`, `linux-aarch64`, `linux-x86_64` + +## EIR and runtime boundary + +- **Typed EIR target**: descriptor-emitted EIR primitives or graph; no opaque builtin call remains. + +## Signature summary + +```php +function __elephc_new_without_constructor(string $class): mixed +``` + +## What the type checker enforces + +- **Arity**: takes exactly 1 argument. + +## Eval interpreter (magician) + +_Not callable from eval'd code — the magician interpreter has no entry for this builtin._ + +## Cross-references + +- _No user-facing reference — this is a compiler internal helper._ diff --git a/docs/internals/builtins/_internal/__elephc_normalize_callable.md b/docs/internals/builtins/_internal/__elephc_normalize_callable.md new file mode 100644 index 0000000000..c62f15fab0 --- /dev/null +++ b/docs/internals/builtins/_internal/__elephc_normalize_callable.md @@ -0,0 +1,53 @@ +--- +title: "__elephc_normalize_callable() — internals" +description: "Compiler internals for __elephc_normalize_callable(): lowering path, type checks, and runtime helpers." +sidebar: + order: 472 +--- + +## `__elephc_normalize_callable()` — internals + +## Where it lives + +- **Signature**: [`src/builtins/pointers/elephc_normalize_callable.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/elephc_normalize_callable.rs) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) +- **Function symbol**: `lower_registry_call()` + + +### Lowering notes + +- Uses the `eir_primitive` strategy from the single-source builtin descriptor. +- Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`. + +## Semantic descriptor + +- **Target strategy**: `eir_primitive` +- **Validation**: `checker_hook` +- **Result type source**: `checked` +- **Result ownership**: `fresh` +- **Effects**: `static (3 declared effects)` +- **Requirements**: `static (0 requirements)` +- **Callable policy**: `static_only` +- **Target support**: `macos-aarch64`, `linux-aarch64`, `linux-x86_64` + +## EIR and runtime boundary + +- **Typed EIR target**: descriptor-emitted EIR primitives or graph; no opaque builtin call remains. + +## Signature summary + +```php +function __elephc_normalize_callable(mixed $value): mixed +``` + +## What the type checker enforces + +- **Arity**: takes exactly 1 argument. + +## Eval interpreter (magician) + +_Not callable from eval'd code — the magician interpreter has no entry for this builtin._ + +## Cross-references + +- _No user-facing reference — this is a compiler internal helper._ diff --git a/docs/internals/builtins/_internal/__elephc_pdo_adapter_addr.md b/docs/internals/builtins/_internal/__elephc_pdo_adapter_addr.md new file mode 100644 index 0000000000..b1e4d8f1ee --- /dev/null +++ b/docs/internals/builtins/_internal/__elephc_pdo_adapter_addr.md @@ -0,0 +1,53 @@ +--- +title: "__elephc_pdo_adapter_addr() — internals" +description: "Compiler internals for __elephc_pdo_adapter_addr(): lowering path, type checks, and runtime helpers." +sidebar: + order: 473 +--- + +## `__elephc_pdo_adapter_addr()` — internals + +## Where it lives + +- **Signature**: [`src/builtins/pointers/elephc_pdo_adapter_addr.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/elephc_pdo_adapter_addr.rs) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) +- **Function symbol**: `lower_registry_call()` + + +### Lowering notes + +- Uses the `eir_primitive` strategy from the single-source builtin descriptor. +- Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`. + +## Semantic descriptor + +- **Target strategy**: `eir_primitive` +- **Validation**: `checker_hook` +- **Result type source**: `checked` +- **Result ownership**: `non_heap` +- **Effects**: `static (0 declared effects)` +- **Requirements**: `static (0 requirements)` +- **Callable policy**: `static_only` +- **Target support**: `macos-aarch64`, `linux-aarch64`, `linux-x86_64` + +## EIR and runtime boundary + +- **Typed EIR target**: descriptor-emitted EIR primitives or graph; no opaque builtin call remains. + +## Signature summary + +```php +function __elephc_pdo_adapter_addr(int $kind): mixed +``` + +## What the type checker enforces + +- **Arity**: takes exactly 1 argument. + +## Eval interpreter (magician) + +_Not callable from eval'd code — the magician interpreter has no entry for this builtin._ + +## Cross-references + +- _No user-facing reference — this is a compiler internal helper._ diff --git a/docs/internals/builtins/_internal/__elephc_pdo_called_class_status.md b/docs/internals/builtins/_internal/__elephc_pdo_called_class_status.md new file mode 100644 index 0000000000..e72caa848f --- /dev/null +++ b/docs/internals/builtins/_internal/__elephc_pdo_called_class_status.md @@ -0,0 +1,53 @@ +--- +title: "__elephc_pdo_called_class_status() — internals" +description: "Compiler internals for __elephc_pdo_called_class_status(): lowering path, type checks, and runtime helpers." +sidebar: + order: 474 +--- + +## `__elephc_pdo_called_class_status()` — internals + +## Where it lives + +- **Signature**: [`src/builtins/system/__elephc_pdo_called_class_status.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/__elephc_pdo_called_class_status.rs) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) +- **Function symbol**: `lower_registry_call()` + + +### Lowering notes + +- Uses the `eir_primitive` strategy from the single-source builtin descriptor. +- Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`. + +## Semantic descriptor + +- **Target strategy**: `eir_primitive` +- **Validation**: `signature` +- **Result type source**: `declared` +- **Result ownership**: `non_heap` +- **Effects**: `static (0 declared effects)` +- **Requirements**: `static (0 requirements)` +- **Callable policy**: `static_only` +- **Target support**: `macos-aarch64`, `linux-aarch64`, `linux-x86_64` + +## EIR and runtime boundary + +- **Typed EIR target**: descriptor-emitted EIR primitives or graph; no opaque builtin call remains. + +## Signature summary + +```php +function __elephc_pdo_called_class_status(string $class): int +``` + +## What the type checker enforces + +- **Arity**: takes exactly 1 argument. + +## Eval interpreter (magician) + +_Not callable from eval'd code — the magician interpreter has no entry for this builtin._ + +## Cross-references + +- _No user-facing reference — this is a compiler internal helper._ diff --git a/docs/internals/builtins/_internal/__elephc_pdo_statement_class_status.md b/docs/internals/builtins/_internal/__elephc_pdo_statement_class_status.md new file mode 100644 index 0000000000..91e195dc66 --- /dev/null +++ b/docs/internals/builtins/_internal/__elephc_pdo_statement_class_status.md @@ -0,0 +1,53 @@ +--- +title: "__elephc_pdo_statement_class_status() — internals" +description: "Compiler internals for __elephc_pdo_statement_class_status(): lowering path, type checks, and runtime helpers." +sidebar: + order: 475 +--- + +## `__elephc_pdo_statement_class_status()` — internals + +## Where it lives + +- **Signature**: [`src/builtins/system/__elephc_pdo_statement_class_status.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/__elephc_pdo_statement_class_status.rs) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) +- **Function symbol**: `lower_registry_call()` + + +### Lowering notes + +- Uses the `eir_primitive` strategy from the single-source builtin descriptor. +- Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`. + +## Semantic descriptor + +- **Target strategy**: `eir_primitive` +- **Validation**: `signature` +- **Result type source**: `declared` +- **Result ownership**: `non_heap` +- **Effects**: `static (0 declared effects)` +- **Requirements**: `static (0 requirements)` +- **Callable policy**: `static_only` +- **Target support**: `macos-aarch64`, `linux-aarch64`, `linux-x86_64` + +## EIR and runtime boundary + +- **Typed EIR target**: descriptor-emitted EIR primitives or graph; no opaque builtin call remains. + +## Signature summary + +```php +function __elephc_pdo_statement_class_status(string $class): int +``` + +## What the type checker enforces + +- **Arity**: takes exactly 1 argument. + +## Eval interpreter (magician) + +_Not callable from eval'd code — the magician interpreter has no entry for this builtin._ + +## Cross-references + +- _No user-facing reference — this is a compiler internal helper._ diff --git a/docs/internals/builtins/_internal/__elephc_phar_bzip2_archive.md b/docs/internals/builtins/_internal/__elephc_phar_bzip2_archive.md index 45fe4a8f55..a7dc5c6db0 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_bzip2_archive.md +++ b/docs/internals/builtins/_internal/__elephc_phar_bzip2_archive.md @@ -2,7 +2,7 @@ title: "__elephc_phar_bzip2_archive() — internals" description: "Compiler internals for __elephc_phar_bzip2_archive(): lowering path, type checks, and runtime helpers." sidebar: - order: 467 + order: 476 --- ## `__elephc_phar_bzip2_archive()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_bzip2_archive.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_bzip2_archive.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_decompress_archive.md b/docs/internals/builtins/_internal/__elephc_phar_decompress_archive.md index 85412193a5..5ee47cbbfc 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_decompress_archive.md +++ b/docs/internals/builtins/_internal/__elephc_phar_decompress_archive.md @@ -2,7 +2,7 @@ title: "__elephc_phar_decompress_archive() — internals" description: "Compiler internals for __elephc_phar_decompress_archive(): lowering path, type checks, and runtime helpers." sidebar: - order: 468 + order: 477 --- ## `__elephc_phar_decompress_archive()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_decompress_archive.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_decompress_archive.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_get_file_metadata.md b/docs/internals/builtins/_internal/__elephc_phar_get_file_metadata.md index 5b2cc173dd..35f802e9ba 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_get_file_metadata.md +++ b/docs/internals/builtins/_internal/__elephc_phar_get_file_metadata.md @@ -2,7 +2,7 @@ title: "__elephc_phar_get_file_metadata() — internals" description: "Compiler internals for __elephc_phar_get_file_metadata(): lowering path, type checks, and runtime helpers." sidebar: - order: 469 + order: 478 --- ## `__elephc_phar_get_file_metadata()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_get_file_metadata.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_get_file_metadata.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_get_metadata.md b/docs/internals/builtins/_internal/__elephc_phar_get_metadata.md index 7212f9822e..b4aadcf213 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_get_metadata.md +++ b/docs/internals/builtins/_internal/__elephc_phar_get_metadata.md @@ -2,7 +2,7 @@ title: "__elephc_phar_get_metadata() — internals" description: "Compiler internals for __elephc_phar_get_metadata(): lowering path, type checks, and runtime helpers." sidebar: - order: 470 + order: 479 --- ## `__elephc_phar_get_metadata()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_get_metadata.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_get_metadata.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_get_signature_hash.md b/docs/internals/builtins/_internal/__elephc_phar_get_signature_hash.md index fc0eada9ba..888d170776 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_get_signature_hash.md +++ b/docs/internals/builtins/_internal/__elephc_phar_get_signature_hash.md @@ -2,7 +2,7 @@ title: "__elephc_phar_get_signature_hash() — internals" description: "Compiler internals for __elephc_phar_get_signature_hash(): lowering path, type checks, and runtime helpers." sidebar: - order: 471 + order: 480 --- ## `__elephc_phar_get_signature_hash()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_get_signature_hash.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_get_signature_hash.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_get_signature_type.md b/docs/internals/builtins/_internal/__elephc_phar_get_signature_type.md index 3b6fc13372..704947bcbb 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_get_signature_type.md +++ b/docs/internals/builtins/_internal/__elephc_phar_get_signature_type.md @@ -2,7 +2,7 @@ title: "__elephc_phar_get_signature_type() — internals" description: "Compiler internals for __elephc_phar_get_signature_type(): lowering path, type checks, and runtime helpers." sidebar: - order: 472 + order: 481 --- ## `__elephc_phar_get_signature_type()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_get_signature_type.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_get_signature_type.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_get_stub.md b/docs/internals/builtins/_internal/__elephc_phar_get_stub.md index 73f466d3f2..72690aa0c7 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_get_stub.md +++ b/docs/internals/builtins/_internal/__elephc_phar_get_stub.md @@ -2,7 +2,7 @@ title: "__elephc_phar_get_stub() — internals" description: "Compiler internals for __elephc_phar_get_stub(): lowering path, type checks, and runtime helpers." sidebar: - order: 473 + order: 482 --- ## `__elephc_phar_get_stub()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_get_stub.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_get_stub.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_gzip_archive.md b/docs/internals/builtins/_internal/__elephc_phar_gzip_archive.md index 881e042c54..51d66b18b8 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_gzip_archive.md +++ b/docs/internals/builtins/_internal/__elephc_phar_gzip_archive.md @@ -2,7 +2,7 @@ title: "__elephc_phar_gzip_archive() — internals" description: "Compiler internals for __elephc_phar_gzip_archive(): lowering path, type checks, and runtime helpers." sidebar: - order: 474 + order: 483 --- ## `__elephc_phar_gzip_archive()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_gzip_archive.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_gzip_archive.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_list_entries.md b/docs/internals/builtins/_internal/__elephc_phar_list_entries.md index 95468dafb6..8b0bb00a4d 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_list_entries.md +++ b/docs/internals/builtins/_internal/__elephc_phar_list_entries.md @@ -2,7 +2,7 @@ title: "__elephc_phar_list_entries() — internals" description: "Compiler internals for __elephc_phar_list_entries(): lowering path, type checks, and runtime helpers." sidebar: - order: 475 + order: 484 --- ## `__elephc_phar_list_entries()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_list_entries.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_list_entries.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_set_compression.md b/docs/internals/builtins/_internal/__elephc_phar_set_compression.md index f4fd94dd22..e111b235dc 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_set_compression.md +++ b/docs/internals/builtins/_internal/__elephc_phar_set_compression.md @@ -2,7 +2,7 @@ title: "__elephc_phar_set_compression() — internals" description: "Compiler internals for __elephc_phar_set_compression(): lowering path, type checks, and runtime helpers." sidebar: - order: 476 + order: 485 --- ## `__elephc_phar_set_compression()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_set_compression.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_set_compression.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_set_file_metadata.md b/docs/internals/builtins/_internal/__elephc_phar_set_file_metadata.md index 46666e6204..9d108a6bf5 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_set_file_metadata.md +++ b/docs/internals/builtins/_internal/__elephc_phar_set_file_metadata.md @@ -2,7 +2,7 @@ title: "__elephc_phar_set_file_metadata() — internals" description: "Compiler internals for __elephc_phar_set_file_metadata(): lowering path, type checks, and runtime helpers." sidebar: - order: 477 + order: 486 --- ## `__elephc_phar_set_file_metadata()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_set_file_metadata.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_set_file_metadata.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_set_metadata.md b/docs/internals/builtins/_internal/__elephc_phar_set_metadata.md index 2bada13a43..a7502509e8 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_set_metadata.md +++ b/docs/internals/builtins/_internal/__elephc_phar_set_metadata.md @@ -2,7 +2,7 @@ title: "__elephc_phar_set_metadata() — internals" description: "Compiler internals for __elephc_phar_set_metadata(): lowering path, type checks, and runtime helpers." sidebar: - order: 478 + order: 487 --- ## `__elephc_phar_set_metadata()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_set_metadata.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_set_metadata.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_set_stub.md b/docs/internals/builtins/_internal/__elephc_phar_set_stub.md index b0fc54c5f7..2e2133709e 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_set_stub.md +++ b/docs/internals/builtins/_internal/__elephc_phar_set_stub.md @@ -2,7 +2,7 @@ title: "__elephc_phar_set_stub() — internals" description: "Compiler internals for __elephc_phar_set_stub(): lowering path, type checks, and runtime helpers." sidebar: - order: 479 + order: 488 --- ## `__elephc_phar_set_stub()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_set_stub.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_set_stub.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_set_zip_password.md b/docs/internals/builtins/_internal/__elephc_phar_set_zip_password.md index 85c13ed252..e4bb666edc 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_set_zip_password.md +++ b/docs/internals/builtins/_internal/__elephc_phar_set_zip_password.md @@ -2,7 +2,7 @@ title: "__elephc_phar_set_zip_password() — internals" description: "Compiler internals for __elephc_phar_set_zip_password(): lowering path, type checks, and runtime helpers." sidebar: - order: 480 + order: 489 --- ## `__elephc_phar_set_zip_password()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_set_zip_password.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_set_zip_password.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_sign_hash.md b/docs/internals/builtins/_internal/__elephc_phar_sign_hash.md index 93b3130822..86cd4fe61b 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_sign_hash.md +++ b/docs/internals/builtins/_internal/__elephc_phar_sign_hash.md @@ -2,7 +2,7 @@ title: "__elephc_phar_sign_hash() — internals" description: "Compiler internals for __elephc_phar_sign_hash(): lowering path, type checks, and runtime helpers." sidebar: - order: 481 + order: 490 --- ## `__elephc_phar_sign_hash()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_sign_hash.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_sign_hash.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_phar_sign_openssl.md b/docs/internals/builtins/_internal/__elephc_phar_sign_openssl.md index 30be9768b2..30508cf192 100644 --- a/docs/internals/builtins/_internal/__elephc_phar_sign_openssl.md +++ b/docs/internals/builtins/_internal/__elephc_phar_sign_openssl.md @@ -2,7 +2,7 @@ title: "__elephc_phar_sign_openssl() — internals" description: "Compiler internals for __elephc_phar_sign_openssl(): lowering path, type checks, and runtime helpers." sidebar: - order: 482 + order: 491 --- ## `__elephc_phar_sign_openssl()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/__elephc_phar_sign_openssl.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/__elephc_phar_sign_openssl.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_ptr_is_null.md b/docs/internals/builtins/_internal/__elephc_ptr_is_null.md index d168612a70..c5c819c104 100644 --- a/docs/internals/builtins/_internal/__elephc_ptr_is_null.md +++ b/docs/internals/builtins/_internal/__elephc_ptr_is_null.md @@ -2,7 +2,7 @@ title: "__elephc_ptr_is_null() — internals" description: "Compiler internals for __elephc_ptr_is_null(): lowering path, type checks, and runtime helpers." sidebar: - order: 483 + order: 492 --- ## `__elephc_ptr_is_null()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/__elephc_ptr_is_null.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/__elephc_ptr_is_null.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_ptr_read_string.md b/docs/internals/builtins/_internal/__elephc_ptr_read_string.md index 5fb9697142..45b02c0d41 100644 --- a/docs/internals/builtins/_internal/__elephc_ptr_read_string.md +++ b/docs/internals/builtins/_internal/__elephc_ptr_read_string.md @@ -2,7 +2,7 @@ title: "__elephc_ptr_read_string() — internals" description: "Compiler internals for __elephc_ptr_read_string(): lowering path, type checks, and runtime helpers." sidebar: - order: 484 + order: 493 --- ## `__elephc_ptr_read_string()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/__elephc_ptr_read_string.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/__elephc_ptr_read_string.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_ptr_write_string.md b/docs/internals/builtins/_internal/__elephc_ptr_write_string.md index 62027ab4f6..eff0c3c0d2 100644 --- a/docs/internals/builtins/_internal/__elephc_ptr_write_string.md +++ b/docs/internals/builtins/_internal/__elephc_ptr_write_string.md @@ -2,7 +2,7 @@ title: "__elephc_ptr_write_string() — internals" description: "Compiler internals for __elephc_ptr_write_string(): lowering path, type checks, and runtime helpers." sidebar: - order: 485 + order: 494 --- ## `__elephc_ptr_write_string()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/__elephc_ptr_write_string.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/__elephc_ptr_write_string.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/_internal/__elephc_strtotime_raw.md b/docs/internals/builtins/_internal/__elephc_strtotime_raw.md index 220941ecfa..c1333b8a16 100644 --- a/docs/internals/builtins/_internal/__elephc_strtotime_raw.md +++ b/docs/internals/builtins/_internal/__elephc_strtotime_raw.md @@ -2,7 +2,7 @@ title: "__elephc_strtotime_raw() — internals" description: "Compiler internals for __elephc_strtotime_raw(): lowering path, type checks, and runtime helpers." sidebar: - order: 486 + order: 495 --- ## `__elephc_strtotime_raw()` — internals @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/__elephc_strtotime_raw.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/__elephc_strtotime_raw.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_all.md b/docs/internals/builtins/array/array_all.md index a37900918f..28d8e2322e 100644 --- a/docs/internals/builtins/array/array_all.md +++ b/docs/internals/builtins/array/array_all.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_all.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_all.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_any.md b/docs/internals/builtins/array/array_any.md index fe2e3b0fca..9e1d74988c 100644 --- a/docs/internals/builtins/array/array_any.md +++ b/docs/internals/builtins/array/array_any.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_any.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_any.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_chunk.md b/docs/internals/builtins/array/array_chunk.md index dc5277591e..73094b18af 100644 --- a/docs/internals/builtins/array/array_chunk.md +++ b/docs/internals/builtins/array/array_chunk.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_chunk.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_chunk.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_column.md b/docs/internals/builtins/array/array_column.md index c3345bd640..877cfd5201 100644 --- a/docs/internals/builtins/array/array_column.md +++ b/docs/internals/builtins/array/array_column.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_column.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_column.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_combine.md b/docs/internals/builtins/array/array_combine.md index babc5aba2b..d1dd747cc8 100644 --- a/docs/internals/builtins/array/array_combine.md +++ b/docs/internals/builtins/array/array_combine.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_combine.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_combine.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_diff.md b/docs/internals/builtins/array/array_diff.md index 671f8b2836..02a517cc54 100644 --- a/docs/internals/builtins/array/array_diff.md +++ b/docs/internals/builtins/array/array_diff.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_diff.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_diff.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_diff_assoc.md b/docs/internals/builtins/array/array_diff_assoc.md index 783fbcc4a1..471328604d 100644 --- a/docs/internals/builtins/array/array_diff_assoc.md +++ b/docs/internals/builtins/array/array_diff_assoc.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_diff_assoc.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_diff_assoc.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_diff_key.md b/docs/internals/builtins/array/array_diff_key.md index c1d276a820..5b9338c2ba 100644 --- a/docs/internals/builtins/array/array_diff_key.md +++ b/docs/internals/builtins/array/array_diff_key.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_diff_key.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_diff_key.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_fill.md b/docs/internals/builtins/array/array_fill.md index 37c6bc56a6..2c177018e3 100644 --- a/docs/internals/builtins/array/array_fill.md +++ b/docs/internals/builtins/array/array_fill.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_fill.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_fill.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_fill_keys.md b/docs/internals/builtins/array/array_fill_keys.md index 4d6fb301b2..a594ef0291 100644 --- a/docs/internals/builtins/array/array_fill_keys.md +++ b/docs/internals/builtins/array/array_fill_keys.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_fill_keys.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_fill_keys.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_filter.md b/docs/internals/builtins/array/array_filter.md index 0076d873d6..bc5196d040 100644 --- a/docs/internals/builtins/array/array_filter.md +++ b/docs/internals/builtins/array/array_filter.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_filter.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_filter.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_find.md b/docs/internals/builtins/array/array_find.md index 13c0c4ab3e..e13080a6ec 100644 --- a/docs/internals/builtins/array/array_find.md +++ b/docs/internals/builtins/array/array_find.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_find.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_find.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_flip.md b/docs/internals/builtins/array/array_flip.md index 77dde66a54..dcea2fc417 100644 --- a/docs/internals/builtins/array/array_flip.md +++ b/docs/internals/builtins/array/array_flip.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_flip.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_flip.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_intersect.md b/docs/internals/builtins/array/array_intersect.md index 7b6cf988b7..e0a913e442 100644 --- a/docs/internals/builtins/array/array_intersect.md +++ b/docs/internals/builtins/array/array_intersect.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_intersect.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_intersect.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_intersect_assoc.md b/docs/internals/builtins/array/array_intersect_assoc.md index 6212873829..48b3c478bc 100644 --- a/docs/internals/builtins/array/array_intersect_assoc.md +++ b/docs/internals/builtins/array/array_intersect_assoc.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_intersect_assoc.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_intersect_assoc.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_intersect_key.md b/docs/internals/builtins/array/array_intersect_key.md index c404e06395..85dc0801f0 100644 --- a/docs/internals/builtins/array/array_intersect_key.md +++ b/docs/internals/builtins/array/array_intersect_key.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_intersect_key.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_intersect_key.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_is_list.md b/docs/internals/builtins/array/array_is_list.md index 4cd7bf9d2c..2ce5d4b8cf 100644 --- a/docs/internals/builtins/array/array_is_list.md +++ b/docs/internals/builtins/array/array_is_list.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_is_list.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_is_list.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_key_exists.md b/docs/internals/builtins/array/array_key_exists.md index 279abe835c..d1806bf9c1 100644 --- a/docs/internals/builtins/array/array_key_exists.md +++ b/docs/internals/builtins/array/array_key_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_key_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_key_exists.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_key_first.md b/docs/internals/builtins/array/array_key_first.md index b79ebeea38..b1ec825efd 100644 --- a/docs/internals/builtins/array/array_key_first.md +++ b/docs/internals/builtins/array/array_key_first.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_key_first.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_key_first.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_key_last.md b/docs/internals/builtins/array/array_key_last.md index 730431da87..e4e1e3dba1 100644 --- a/docs/internals/builtins/array/array_key_last.md +++ b/docs/internals/builtins/array/array_key_last.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_key_last.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_key_last.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_keys.md b/docs/internals/builtins/array/array_keys.md index 25f228afc1..5ae6a62a0b 100644 --- a/docs/internals/builtins/array/array_keys.md +++ b/docs/internals/builtins/array/array_keys.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_keys.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_keys.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_map.md b/docs/internals/builtins/array/array_map.md index 0232a5c6a9..9dd3019e64 100644 --- a/docs/internals/builtins/array/array_map.md +++ b/docs/internals/builtins/array/array_map.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_map.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_map.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_merge.md b/docs/internals/builtins/array/array_merge.md index 56ad5dad6a..d270cac156 100644 --- a/docs/internals/builtins/array/array_merge.md +++ b/docs/internals/builtins/array/array_merge.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_merge.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_merge.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_merge_recursive.md b/docs/internals/builtins/array/array_merge_recursive.md index 4ec6eefd70..d3f7ff0e7a 100644 --- a/docs/internals/builtins/array/array_merge_recursive.md +++ b/docs/internals/builtins/array/array_merge_recursive.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_merge_recursive.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_merge_recursive.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_multisort.md b/docs/internals/builtins/array/array_multisort.md index 6c6deba2bf..4e494bc183 100644 --- a/docs/internals/builtins/array/array_multisort.md +++ b/docs/internals/builtins/array/array_multisort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_multisort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_multisort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_pad.md b/docs/internals/builtins/array/array_pad.md index 0a6d98264f..bceca6273a 100644 --- a/docs/internals/builtins/array/array_pad.md +++ b/docs/internals/builtins/array/array_pad.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_pad.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_pad.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_pop.md b/docs/internals/builtins/array/array_pop.md index f8e956bfbf..aa4675a690 100644 --- a/docs/internals/builtins/array/array_pop.md +++ b/docs/internals/builtins/array/array_pop.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_pop.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_pop.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_product.md b/docs/internals/builtins/array/array_product.md index 10f8817db6..38964fdfc1 100644 --- a/docs/internals/builtins/array/array_product.md +++ b/docs/internals/builtins/array/array_product.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_product.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_product.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_push.md b/docs/internals/builtins/array/array_push.md index b4669bcc56..089ce72896 100644 --- a/docs/internals/builtins/array/array_push.md +++ b/docs/internals/builtins/array/array_push.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_push.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_push.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_rand.md b/docs/internals/builtins/array/array_rand.md index d2b7868097..3243ac7c42 100644 --- a/docs/internals/builtins/array/array_rand.md +++ b/docs/internals/builtins/array/array_rand.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_rand.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_rand.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_reduce.md b/docs/internals/builtins/array/array_reduce.md index 4a9440b385..5b20e99844 100644 --- a/docs/internals/builtins/array/array_reduce.md +++ b/docs/internals/builtins/array/array_reduce.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_reduce.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_reduce.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_replace.md b/docs/internals/builtins/array/array_replace.md index 464579963e..719ba421e9 100644 --- a/docs/internals/builtins/array/array_replace.md +++ b/docs/internals/builtins/array/array_replace.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_replace.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_replace.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_replace_recursive.md b/docs/internals/builtins/array/array_replace_recursive.md index 2ca9b37ac7..d8cc6ae3b2 100644 --- a/docs/internals/builtins/array/array_replace_recursive.md +++ b/docs/internals/builtins/array/array_replace_recursive.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_replace_recursive.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_replace_recursive.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_reverse.md b/docs/internals/builtins/array/array_reverse.md index c5ec00f202..c81e1eda86 100644 --- a/docs/internals/builtins/array/array_reverse.md +++ b/docs/internals/builtins/array/array_reverse.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_reverse.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_reverse.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_search.md b/docs/internals/builtins/array/array_search.md index 7ceaae13ab..912edb7fcd 100644 --- a/docs/internals/builtins/array/array_search.md +++ b/docs/internals/builtins/array/array_search.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_search.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_search.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_shift.md b/docs/internals/builtins/array/array_shift.md index aae3a1b23f..339a8cd5f8 100644 --- a/docs/internals/builtins/array/array_shift.md +++ b/docs/internals/builtins/array/array_shift.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_shift.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_shift.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_slice.md b/docs/internals/builtins/array/array_slice.md index 615c33994d..31d763c7e8 100644 --- a/docs/internals/builtins/array/array_slice.md +++ b/docs/internals/builtins/array/array_slice.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_slice.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_slice.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_splice.md b/docs/internals/builtins/array/array_splice.md index 7f91c52248..1c7d24dd47 100644 --- a/docs/internals/builtins/array/array_splice.md +++ b/docs/internals/builtins/array/array_splice.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_splice.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_splice.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_sum.md b/docs/internals/builtins/array/array_sum.md index 47e681128c..00040b5b22 100644 --- a/docs/internals/builtins/array/array_sum.md +++ b/docs/internals/builtins/array/array_sum.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_sum.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_sum.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_udiff.md b/docs/internals/builtins/array/array_udiff.md index 9f3605ea38..986beb6442 100644 --- a/docs/internals/builtins/array/array_udiff.md +++ b/docs/internals/builtins/array/array_udiff.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_udiff.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_udiff.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_uintersect.md b/docs/internals/builtins/array/array_uintersect.md index 0bfd9a06a6..49dc06e4c1 100644 --- a/docs/internals/builtins/array/array_uintersect.md +++ b/docs/internals/builtins/array/array_uintersect.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_uintersect.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_uintersect.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_unique.md b/docs/internals/builtins/array/array_unique.md index baabff417c..f657598ea5 100644 --- a/docs/internals/builtins/array/array_unique.md +++ b/docs/internals/builtins/array/array_unique.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_unique.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_unique.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_unshift.md b/docs/internals/builtins/array/array_unshift.md index d888b18ac2..cc93e713ed 100644 --- a/docs/internals/builtins/array/array_unshift.md +++ b/docs/internals/builtins/array/array_unshift.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_unshift.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_unshift.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_values.md b/docs/internals/builtins/array/array_values.md index 7e541e5b48..1818524840 100644 --- a/docs/internals/builtins/array/array_values.md +++ b/docs/internals/builtins/array/array_values.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_values.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_values.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_walk.md b/docs/internals/builtins/array/array_walk.md index 233f713813..e898537c44 100644 --- a/docs/internals/builtins/array/array_walk.md +++ b/docs/internals/builtins/array/array_walk.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_walk.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_walk.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/array_walk_recursive.md b/docs/internals/builtins/array/array_walk_recursive.md index 10c0e56f5a..049c96f994 100644 --- a/docs/internals/builtins/array/array_walk_recursive.md +++ b/docs/internals/builtins/array/array_walk_recursive.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/array_walk_recursive.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/array_walk_recursive.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/arsort.md b/docs/internals/builtins/array/arsort.md index 8f5f621809..3be1f0a205 100644 --- a/docs/internals/builtins/array/arsort.md +++ b/docs/internals/builtins/array/arsort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/arsort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/arsort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/asort.md b/docs/internals/builtins/array/asort.md index f507229414..2f92f3391a 100644 --- a/docs/internals/builtins/array/asort.md +++ b/docs/internals/builtins/array/asort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/asort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/asort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/call_user_func.md b/docs/internals/builtins/array/call_user_func.md index 062f224927..af4260cf8c 100644 --- a/docs/internals/builtins/array/call_user_func.md +++ b/docs/internals/builtins/array/call_user_func.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/call_user_func.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/call_user_func.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/call_user_func_array.md b/docs/internals/builtins/array/call_user_func_array.md index e03fb70228..17c2a1ba2c 100644 --- a/docs/internals/builtins/array/call_user_func_array.md +++ b/docs/internals/builtins/array/call_user_func_array.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/call_user_func_array.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/call_user_func_array.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/count.md b/docs/internals/builtins/array/count.md index b8b0403dfb..a74ae56c12 100644 --- a/docs/internals/builtins/array/count.md +++ b/docs/internals/builtins/array/count.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/count.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/count.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/in_array.md b/docs/internals/builtins/array/in_array.md index 59c343fdff..139dc653e4 100644 --- a/docs/internals/builtins/array/in_array.md +++ b/docs/internals/builtins/array/in_array.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/in_array.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/in_array.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/krsort.md b/docs/internals/builtins/array/krsort.md index 99a1cb71a3..ee91eefd4d 100644 --- a/docs/internals/builtins/array/krsort.md +++ b/docs/internals/builtins/array/krsort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/krsort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/krsort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/ksort.md b/docs/internals/builtins/array/ksort.md index 559fd2e180..900d93ee35 100644 --- a/docs/internals/builtins/array/ksort.md +++ b/docs/internals/builtins/array/ksort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/ksort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/ksort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/natcasesort.md b/docs/internals/builtins/array/natcasesort.md index ed067a771d..aaffe78fe1 100644 --- a/docs/internals/builtins/array/natcasesort.md +++ b/docs/internals/builtins/array/natcasesort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/natcasesort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/natcasesort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/natsort.md b/docs/internals/builtins/array/natsort.md index bd2ffdc2ba..100ad05cc3 100644 --- a/docs/internals/builtins/array/natsort.md +++ b/docs/internals/builtins/array/natsort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/natsort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/natsort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/range.md b/docs/internals/builtins/array/range.md index 2e5fdc86e8..e25adb2a41 100644 --- a/docs/internals/builtins/array/range.md +++ b/docs/internals/builtins/array/range.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/range.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/range.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/rsort.md b/docs/internals/builtins/array/rsort.md index dfc4c88797..4760fda420 100644 --- a/docs/internals/builtins/array/rsort.md +++ b/docs/internals/builtins/array/rsort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/rsort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/rsort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/shuffle.md b/docs/internals/builtins/array/shuffle.md index 16a0eef9a0..6411b31e4b 100644 --- a/docs/internals/builtins/array/shuffle.md +++ b/docs/internals/builtins/array/shuffle.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/shuffle.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/shuffle.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/sort.md b/docs/internals/builtins/array/sort.md index 1165ca3f07..c3bc18387e 100644 --- a/docs/internals/builtins/array/sort.md +++ b/docs/internals/builtins/array/sort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/sort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/sort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/uasort.md b/docs/internals/builtins/array/uasort.md index 6a3afae267..1c004be9b9 100644 --- a/docs/internals/builtins/array/uasort.md +++ b/docs/internals/builtins/array/uasort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/uasort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/uasort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/uksort.md b/docs/internals/builtins/array/uksort.md index 53de407b32..e5be4cfd65 100644 --- a/docs/internals/builtins/array/uksort.md +++ b/docs/internals/builtins/array/uksort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/uksort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/uksort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/array/usort.md b/docs/internals/builtins/array/usort.md index 73822cf161..e1f10a62f1 100644 --- a/docs/internals/builtins/array/usort.md +++ b/docs/internals/builtins/array/usort.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/array/usort.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/array/usort.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/buffer/buffer_free.md b/docs/internals/builtins/buffer/buffer_free.md index 79e77fb4c6..c77febbc26 100644 --- a/docs/internals/builtins/buffer/buffer_free.md +++ b/docs/internals/builtins/buffer/buffer_free.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/buffer_free.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/buffer_free.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/buffer/buffer_len.md b/docs/internals/builtins/buffer/buffer_len.md index a9dab43775..e1a6d53786 100644 --- a/docs/internals/builtins/buffer/buffer_len.md +++ b/docs/internals/builtins/buffer/buffer_len.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/buffer_len.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/buffer_len.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/class_alias.md b/docs/internals/builtins/class/class_alias.md index f5528a3d94..184a49cd5e 100644 --- a/docs/internals/builtins/class/class_alias.md +++ b/docs/internals/builtins/class/class_alias.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/class_alias.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/class_alias.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/class_attribute_args.md b/docs/internals/builtins/class/class_attribute_args.md index 670acee18c..b11286082e 100644 --- a/docs/internals/builtins/class/class_attribute_args.md +++ b/docs/internals/builtins/class/class_attribute_args.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/class_attribute_args.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/class_attribute_args.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/class_attribute_names.md b/docs/internals/builtins/class/class_attribute_names.md index 11c055b1fe..992947a798 100644 --- a/docs/internals/builtins/class/class_attribute_names.md +++ b/docs/internals/builtins/class/class_attribute_names.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/class_attribute_names.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/class_attribute_names.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/class_exists.md b/docs/internals/builtins/class/class_exists.md index ce881419d6..5d9a604609 100644 --- a/docs/internals/builtins/class/class_exists.md +++ b/docs/internals/builtins/class/class_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/class_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/class_exists.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/class_get_attributes.md b/docs/internals/builtins/class/class_get_attributes.md index 34e78ff7b9..c51d7c0204 100644 --- a/docs/internals/builtins/class/class_get_attributes.md +++ b/docs/internals/builtins/class/class_get_attributes.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/class_get_attributes.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/class_get_attributes.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/class_implements.md b/docs/internals/builtins/class/class_implements.md index 7eba8724c2..33c011fe59 100644 --- a/docs/internals/builtins/class/class_implements.md +++ b/docs/internals/builtins/class/class_implements.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/class_implements.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/class_implements.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/class_parents.md b/docs/internals/builtins/class/class_parents.md index f7e0a0ae33..002e6ccaa1 100644 --- a/docs/internals/builtins/class/class_parents.md +++ b/docs/internals/builtins/class/class_parents.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/class_parents.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/class_parents.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/class_uses.md b/docs/internals/builtins/class/class_uses.md index 6be9cac631..68e47eabaf 100644 --- a/docs/internals/builtins/class/class_uses.md +++ b/docs/internals/builtins/class/class_uses.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/class_uses.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/class_uses.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/enum_exists.md b/docs/internals/builtins/class/enum_exists.md index fc3daef966..48f23d6fb6 100644 --- a/docs/internals/builtins/class/enum_exists.md +++ b/docs/internals/builtins/class/enum_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/enum_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/enum_exists.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/function_exists.md b/docs/internals/builtins/class/function_exists.md index 5d29b0726d..fb2714c755 100644 --- a/docs/internals/builtins/class/function_exists.md +++ b/docs/internals/builtins/class/function_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/function_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/function_exists.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/get_class.md b/docs/internals/builtins/class/get_class.md index 6dda3e22f0..a503f39ca1 100644 --- a/docs/internals/builtins/class/get_class.md +++ b/docs/internals/builtins/class/get_class.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/get_class.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/get_class.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/get_declared_classes.md b/docs/internals/builtins/class/get_declared_classes.md index c1fb894fb7..7a37dcc7dd 100644 --- a/docs/internals/builtins/class/get_declared_classes.md +++ b/docs/internals/builtins/class/get_declared_classes.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/get_declared_classes.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/get_declared_classes.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/get_declared_interfaces.md b/docs/internals/builtins/class/get_declared_interfaces.md index c7f3d5f7aa..44f754cedf 100644 --- a/docs/internals/builtins/class/get_declared_interfaces.md +++ b/docs/internals/builtins/class/get_declared_interfaces.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/get_declared_interfaces.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/get_declared_interfaces.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/get_declared_traits.md b/docs/internals/builtins/class/get_declared_traits.md index 2235e6bbf0..9ca679cd97 100644 --- a/docs/internals/builtins/class/get_declared_traits.md +++ b/docs/internals/builtins/class/get_declared_traits.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/get_declared_traits.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/get_declared_traits.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/get_parent_class.md b/docs/internals/builtins/class/get_parent_class.md index e6768c8655..ddcc193316 100644 --- a/docs/internals/builtins/class/get_parent_class.md +++ b/docs/internals/builtins/class/get_parent_class.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/get_parent_class.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/get_parent_class.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/interface_exists.md b/docs/internals/builtins/class/interface_exists.md index 88bd51d315..d538d1f6f6 100644 --- a/docs/internals/builtins/class/interface_exists.md +++ b/docs/internals/builtins/class/interface_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/interface_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/interface_exists.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/is_a.md b/docs/internals/builtins/class/is_a.md index 16401dc517..a997d94244 100644 --- a/docs/internals/builtins/class/is_a.md +++ b/docs/internals/builtins/class/is_a.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/is_a.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/is_a.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/is_subclass_of.md b/docs/internals/builtins/class/is_subclass_of.md index 3ed8254042..362d74c829 100644 --- a/docs/internals/builtins/class/is_subclass_of.md +++ b/docs/internals/builtins/class/is_subclass_of.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/is_subclass_of.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/is_subclass_of.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/method_exists.md b/docs/internals/builtins/class/method_exists.md index bbb0a22e29..d2a2ec0242 100644 --- a/docs/internals/builtins/class/method_exists.md +++ b/docs/internals/builtins/class/method_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/method_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/method_exists.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/property_exists.md b/docs/internals/builtins/class/property_exists.md index 7d591a791f..09498cab73 100644 --- a/docs/internals/builtins/class/property_exists.md +++ b/docs/internals/builtins/class/property_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/property_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/property_exists.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/class/trait_exists.md b/docs/internals/builtins/class/trait_exists.md index b8c6664744..adecf06c3f 100644 --- a/docs/internals/builtins/class/trait_exists.md +++ b/docs/internals/builtins/class/trait_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/trait_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/trait_exists.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/checkdate.md b/docs/internals/builtins/date/checkdate.md index 5f3328d14a..25de9fac83 100644 --- a/docs/internals/builtins/date/checkdate.md +++ b/docs/internals/builtins/date/checkdate.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/checkdate.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/checkdate.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/date.md b/docs/internals/builtins/date/date.md index 1484776660..e681f1cb9e 100644 --- a/docs/internals/builtins/date/date.md +++ b/docs/internals/builtins/date/date.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/date.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/date.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/date_default_timezone_get.md b/docs/internals/builtins/date/date_default_timezone_get.md index c72ffba1a8..0c66ae0117 100644 --- a/docs/internals/builtins/date/date_default_timezone_get.md +++ b/docs/internals/builtins/date/date_default_timezone_get.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/date_default_timezone_get.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/date_default_timezone_get.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/date_default_timezone_set.md b/docs/internals/builtins/date/date_default_timezone_set.md index 1f44b1d55f..18748dae59 100644 --- a/docs/internals/builtins/date/date_default_timezone_set.md +++ b/docs/internals/builtins/date/date_default_timezone_set.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/date_default_timezone_set.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/date_default_timezone_set.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/getdate.md b/docs/internals/builtins/date/getdate.md index 446f87fc1a..4e6dfb8be7 100644 --- a/docs/internals/builtins/date/getdate.md +++ b/docs/internals/builtins/date/getdate.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/getdate.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/getdate.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/gmdate.md b/docs/internals/builtins/date/gmdate.md index 32a6104a62..8b5e09fe0d 100644 --- a/docs/internals/builtins/date/gmdate.md +++ b/docs/internals/builtins/date/gmdate.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/gmdate.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/gmdate.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/gmmktime.md b/docs/internals/builtins/date/gmmktime.md index 1b0a1fcd06..b1ea4bc127 100644 --- a/docs/internals/builtins/date/gmmktime.md +++ b/docs/internals/builtins/date/gmmktime.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/gmmktime.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/gmmktime.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/hrtime.md b/docs/internals/builtins/date/hrtime.md index 0d0796dc1e..d315354428 100644 --- a/docs/internals/builtins/date/hrtime.md +++ b/docs/internals/builtins/date/hrtime.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/hrtime.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/hrtime.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/localtime.md b/docs/internals/builtins/date/localtime.md index 88d20049b6..243770addb 100644 --- a/docs/internals/builtins/date/localtime.md +++ b/docs/internals/builtins/date/localtime.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/localtime.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/localtime.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/microtime.md b/docs/internals/builtins/date/microtime.md index c6ef8152fd..3deb258af9 100644 --- a/docs/internals/builtins/date/microtime.md +++ b/docs/internals/builtins/date/microtime.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/microtime.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/microtime.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/mktime.md b/docs/internals/builtins/date/mktime.md index 09d1f512bb..78186a06a3 100644 --- a/docs/internals/builtins/date/mktime.md +++ b/docs/internals/builtins/date/mktime.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/mktime.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/mktime.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/strtotime.md b/docs/internals/builtins/date/strtotime.md index d4f5c1c7df..c6bdde6a8b 100644 --- a/docs/internals/builtins/date/strtotime.md +++ b/docs/internals/builtins/date/strtotime.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/strtotime.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/strtotime.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/date/time.md b/docs/internals/builtins/date/time.md index 7d0df89ef2..e9d1f1b594 100644 --- a/docs/internals/builtins/date/time.md +++ b/docs/internals/builtins/date/time.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/time.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/time.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/basename.md b/docs/internals/builtins/filesystem/basename.md index 4c44e64b88..3a742bf386 100644 --- a/docs/internals/builtins/filesystem/basename.md +++ b/docs/internals/builtins/filesystem/basename.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/basename.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/basename.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/chdir.md b/docs/internals/builtins/filesystem/chdir.md index 22aafa744a..efbb0bae9c 100644 --- a/docs/internals/builtins/filesystem/chdir.md +++ b/docs/internals/builtins/filesystem/chdir.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/chdir.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/chdir.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/chgrp.md b/docs/internals/builtins/filesystem/chgrp.md index 425d561e57..1f3f31cb8d 100644 --- a/docs/internals/builtins/filesystem/chgrp.md +++ b/docs/internals/builtins/filesystem/chgrp.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/chgrp.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/chgrp.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/chmod.md b/docs/internals/builtins/filesystem/chmod.md index 948c79bd5d..c9a47c4b09 100644 --- a/docs/internals/builtins/filesystem/chmod.md +++ b/docs/internals/builtins/filesystem/chmod.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/chmod.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/chmod.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/chown.md b/docs/internals/builtins/filesystem/chown.md index a7708531f3..6177ea3c58 100644 --- a/docs/internals/builtins/filesystem/chown.md +++ b/docs/internals/builtins/filesystem/chown.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/chown.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/chown.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/clearstatcache.md b/docs/internals/builtins/filesystem/clearstatcache.md index 38d1e9726c..2e4ce72c7f 100644 --- a/docs/internals/builtins/filesystem/clearstatcache.md +++ b/docs/internals/builtins/filesystem/clearstatcache.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/clearstatcache.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/clearstatcache.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/copy.md b/docs/internals/builtins/filesystem/copy.md index 4ea25d121b..6fcff4a26b 100644 --- a/docs/internals/builtins/filesystem/copy.md +++ b/docs/internals/builtins/filesystem/copy.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/copy.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/copy.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/dirname.md b/docs/internals/builtins/filesystem/dirname.md index 453170840f..d725635d8c 100644 --- a/docs/internals/builtins/filesystem/dirname.md +++ b/docs/internals/builtins/filesystem/dirname.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/dirname.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/dirname.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/disk_free_space.md b/docs/internals/builtins/filesystem/disk_free_space.md index f761e66695..9e77c267a8 100644 --- a/docs/internals/builtins/filesystem/disk_free_space.md +++ b/docs/internals/builtins/filesystem/disk_free_space.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/disk_free_space.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/disk_free_space.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/disk_total_space.md b/docs/internals/builtins/filesystem/disk_total_space.md index 537d248c8a..ffed9dfcf7 100644 --- a/docs/internals/builtins/filesystem/disk_total_space.md +++ b/docs/internals/builtins/filesystem/disk_total_space.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/disk_total_space.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/disk_total_space.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/file_exists.md b/docs/internals/builtins/filesystem/file_exists.md index 7558dcde20..d090945e1f 100644 --- a/docs/internals/builtins/filesystem/file_exists.md +++ b/docs/internals/builtins/filesystem/file_exists.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/file_exists.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/file_exists.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/fileatime.md b/docs/internals/builtins/filesystem/fileatime.md index 13b65c8e52..eab7694a78 100644 --- a/docs/internals/builtins/filesystem/fileatime.md +++ b/docs/internals/builtins/filesystem/fileatime.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fileatime.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fileatime.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/filectime.md b/docs/internals/builtins/filesystem/filectime.md index c3b6db4bbd..1060c028f5 100644 --- a/docs/internals/builtins/filesystem/filectime.md +++ b/docs/internals/builtins/filesystem/filectime.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/filectime.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/filectime.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/filegroup.md b/docs/internals/builtins/filesystem/filegroup.md index f656adcf79..1ebde20221 100644 --- a/docs/internals/builtins/filesystem/filegroup.md +++ b/docs/internals/builtins/filesystem/filegroup.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/filegroup.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/filegroup.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/fileinode.md b/docs/internals/builtins/filesystem/fileinode.md index 2f10c2ac5a..d194b9abf6 100644 --- a/docs/internals/builtins/filesystem/fileinode.md +++ b/docs/internals/builtins/filesystem/fileinode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fileinode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fileinode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/filemtime.md b/docs/internals/builtins/filesystem/filemtime.md index 776bd740a6..14714b3a80 100644 --- a/docs/internals/builtins/filesystem/filemtime.md +++ b/docs/internals/builtins/filesystem/filemtime.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/filemtime.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/filemtime.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/fileowner.md b/docs/internals/builtins/filesystem/fileowner.md index e40b8df8ee..28944a353e 100644 --- a/docs/internals/builtins/filesystem/fileowner.md +++ b/docs/internals/builtins/filesystem/fileowner.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fileowner.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fileowner.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/fileperms.md b/docs/internals/builtins/filesystem/fileperms.md index f4de4158bc..cdacc74efc 100644 --- a/docs/internals/builtins/filesystem/fileperms.md +++ b/docs/internals/builtins/filesystem/fileperms.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fileperms.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fileperms.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/filesize.md b/docs/internals/builtins/filesystem/filesize.md index 0f1f30ea81..3f75489563 100644 --- a/docs/internals/builtins/filesystem/filesize.md +++ b/docs/internals/builtins/filesystem/filesize.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/filesize.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/filesize.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/filetype.md b/docs/internals/builtins/filesystem/filetype.md index 0fd6afe666..4b35df56cc 100644 --- a/docs/internals/builtins/filesystem/filetype.md +++ b/docs/internals/builtins/filesystem/filetype.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/filetype.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/filetype.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/fnmatch.md b/docs/internals/builtins/filesystem/fnmatch.md index 1f9835bd92..18e5d20381 100644 --- a/docs/internals/builtins/filesystem/fnmatch.md +++ b/docs/internals/builtins/filesystem/fnmatch.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fnmatch.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fnmatch.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/getcwd.md b/docs/internals/builtins/filesystem/getcwd.md index a513a1d7ae..bb1da2f0be 100644 --- a/docs/internals/builtins/filesystem/getcwd.md +++ b/docs/internals/builtins/filesystem/getcwd.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/getcwd.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/getcwd.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/getenv.md b/docs/internals/builtins/filesystem/getenv.md index 60f56abf22..3a0ec1fb95 100644 --- a/docs/internals/builtins/filesystem/getenv.md +++ b/docs/internals/builtins/filesystem/getenv.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/getenv.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/getenv.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/glob.md b/docs/internals/builtins/filesystem/glob.md index 189b5e28f6..1854afc8ca 100644 --- a/docs/internals/builtins/filesystem/glob.md +++ b/docs/internals/builtins/filesystem/glob.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/glob.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/glob.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/is_dir.md b/docs/internals/builtins/filesystem/is_dir.md index 254a3f9cec..0ceaea1dfe 100644 --- a/docs/internals/builtins/filesystem/is_dir.md +++ b/docs/internals/builtins/filesystem/is_dir.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/is_dir.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/is_dir.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/is_executable.md b/docs/internals/builtins/filesystem/is_executable.md index 283609f22f..b5ba7f30f4 100644 --- a/docs/internals/builtins/filesystem/is_executable.md +++ b/docs/internals/builtins/filesystem/is_executable.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/is_executable.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/is_executable.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/is_file.md b/docs/internals/builtins/filesystem/is_file.md index cb1788ed37..132b6fa83b 100644 --- a/docs/internals/builtins/filesystem/is_file.md +++ b/docs/internals/builtins/filesystem/is_file.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/is_file.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/is_file.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/is_link.md b/docs/internals/builtins/filesystem/is_link.md index 51d82e32c8..36392d0881 100644 --- a/docs/internals/builtins/filesystem/is_link.md +++ b/docs/internals/builtins/filesystem/is_link.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/is_link.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/is_link.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/is_readable.md b/docs/internals/builtins/filesystem/is_readable.md index 6ea5aa8c4f..7148706cd8 100644 --- a/docs/internals/builtins/filesystem/is_readable.md +++ b/docs/internals/builtins/filesystem/is_readable.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/is_readable.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/is_readable.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/is_writable.md b/docs/internals/builtins/filesystem/is_writable.md index e310b78bca..c9aaeeec8f 100644 --- a/docs/internals/builtins/filesystem/is_writable.md +++ b/docs/internals/builtins/filesystem/is_writable.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/is_writable.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/is_writable.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/is_writeable.md b/docs/internals/builtins/filesystem/is_writeable.md index 9a6975332f..6325d09f4a 100644 --- a/docs/internals/builtins/filesystem/is_writeable.md +++ b/docs/internals/builtins/filesystem/is_writeable.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/is_writeable.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/is_writeable.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/lchgrp.md b/docs/internals/builtins/filesystem/lchgrp.md index 0846c9f65b..98d9f077bd 100644 --- a/docs/internals/builtins/filesystem/lchgrp.md +++ b/docs/internals/builtins/filesystem/lchgrp.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/lchgrp.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/lchgrp.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/lchown.md b/docs/internals/builtins/filesystem/lchown.md index 23170e54db..9d47e90e68 100644 --- a/docs/internals/builtins/filesystem/lchown.md +++ b/docs/internals/builtins/filesystem/lchown.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/lchown.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/lchown.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/link.md b/docs/internals/builtins/filesystem/link.md index f532cdf885..42c0cbaa5d 100644 --- a/docs/internals/builtins/filesystem/link.md +++ b/docs/internals/builtins/filesystem/link.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/link.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/link.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/linkinfo.md b/docs/internals/builtins/filesystem/linkinfo.md index 693fcfaa37..e26510acc6 100644 --- a/docs/internals/builtins/filesystem/linkinfo.md +++ b/docs/internals/builtins/filesystem/linkinfo.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/linkinfo.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/linkinfo.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/lstat.md b/docs/internals/builtins/filesystem/lstat.md index 6f99b6ab41..09a9a006b8 100644 --- a/docs/internals/builtins/filesystem/lstat.md +++ b/docs/internals/builtins/filesystem/lstat.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/lstat.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/lstat.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/mkdir.md b/docs/internals/builtins/filesystem/mkdir.md index d3496bcb81..5829147904 100644 --- a/docs/internals/builtins/filesystem/mkdir.md +++ b/docs/internals/builtins/filesystem/mkdir.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/mkdir.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/mkdir.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/pathinfo.md b/docs/internals/builtins/filesystem/pathinfo.md index 6892e30f2f..4992d15be7 100644 --- a/docs/internals/builtins/filesystem/pathinfo.md +++ b/docs/internals/builtins/filesystem/pathinfo.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/pathinfo.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/pathinfo.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/putenv.md b/docs/internals/builtins/filesystem/putenv.md index 472a001c54..4711e72778 100644 --- a/docs/internals/builtins/filesystem/putenv.md +++ b/docs/internals/builtins/filesystem/putenv.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/putenv.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/putenv.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/readfile.md b/docs/internals/builtins/filesystem/readfile.md index b66b8d7a0e..1dbecb9fe3 100644 --- a/docs/internals/builtins/filesystem/readfile.md +++ b/docs/internals/builtins/filesystem/readfile.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/readfile.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/readfile.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/readlink.md b/docs/internals/builtins/filesystem/readlink.md index d5169ecc8c..edc5ef8261 100644 --- a/docs/internals/builtins/filesystem/readlink.md +++ b/docs/internals/builtins/filesystem/readlink.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/readlink.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/readlink.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/realpath.md b/docs/internals/builtins/filesystem/realpath.md index f68773d38e..777e4bda8c 100644 --- a/docs/internals/builtins/filesystem/realpath.md +++ b/docs/internals/builtins/filesystem/realpath.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/realpath.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/realpath.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/realpath_cache_get.md b/docs/internals/builtins/filesystem/realpath_cache_get.md index 5976b59a00..88bc38b6e5 100644 --- a/docs/internals/builtins/filesystem/realpath_cache_get.md +++ b/docs/internals/builtins/filesystem/realpath_cache_get.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/realpath_cache_get.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/realpath_cache_get.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/realpath_cache_size.md b/docs/internals/builtins/filesystem/realpath_cache_size.md index cc262c8e7d..182f5990e1 100644 --- a/docs/internals/builtins/filesystem/realpath_cache_size.md +++ b/docs/internals/builtins/filesystem/realpath_cache_size.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/realpath_cache_size.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/realpath_cache_size.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/rename.md b/docs/internals/builtins/filesystem/rename.md index bd2b1cad86..ec8d5980d1 100644 --- a/docs/internals/builtins/filesystem/rename.md +++ b/docs/internals/builtins/filesystem/rename.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/rename.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/rename.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/rmdir.md b/docs/internals/builtins/filesystem/rmdir.md index 1bac4f7142..50db35d237 100644 --- a/docs/internals/builtins/filesystem/rmdir.md +++ b/docs/internals/builtins/filesystem/rmdir.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/rmdir.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/rmdir.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/scandir.md b/docs/internals/builtins/filesystem/scandir.md index 1d29c7e79d..8a1213bbfb 100644 --- a/docs/internals/builtins/filesystem/scandir.md +++ b/docs/internals/builtins/filesystem/scandir.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/scandir.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/scandir.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/stat.md b/docs/internals/builtins/filesystem/stat.md index 1776bededa..73558ffce3 100644 --- a/docs/internals/builtins/filesystem/stat.md +++ b/docs/internals/builtins/filesystem/stat.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stat.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stat.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/symlink.md b/docs/internals/builtins/filesystem/symlink.md index eb51274b9a..903beded32 100644 --- a/docs/internals/builtins/filesystem/symlink.md +++ b/docs/internals/builtins/filesystem/symlink.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/symlink.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/symlink.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/sys_get_temp_dir.md b/docs/internals/builtins/filesystem/sys_get_temp_dir.md index 83bab9c1ef..fd9f530cb4 100644 --- a/docs/internals/builtins/filesystem/sys_get_temp_dir.md +++ b/docs/internals/builtins/filesystem/sys_get_temp_dir.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/sys_get_temp_dir.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/sys_get_temp_dir.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/tempnam.md b/docs/internals/builtins/filesystem/tempnam.md index abbbeab675..8d11b94ddf 100644 --- a/docs/internals/builtins/filesystem/tempnam.md +++ b/docs/internals/builtins/filesystem/tempnam.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/tempnam.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/tempnam.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/tmpfile.md b/docs/internals/builtins/filesystem/tmpfile.md index d6864cb2a5..77ab063ffe 100644 --- a/docs/internals/builtins/filesystem/tmpfile.md +++ b/docs/internals/builtins/filesystem/tmpfile.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/tmpfile.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/tmpfile.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/touch.md b/docs/internals/builtins/filesystem/touch.md index 4cec86705f..9732b81524 100644 --- a/docs/internals/builtins/filesystem/touch.md +++ b/docs/internals/builtins/filesystem/touch.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/touch.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/touch.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/umask.md b/docs/internals/builtins/filesystem/umask.md index b8ef43b340..8de5698434 100644 --- a/docs/internals/builtins/filesystem/umask.md +++ b/docs/internals/builtins/filesystem/umask.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/umask.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/umask.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/filesystem/unlink.md b/docs/internals/builtins/filesystem/unlink.md index 4e90c4a616..8f147789f5 100644 --- a/docs/internals/builtins/filesystem/unlink.md +++ b/docs/internals/builtins/filesystem/unlink.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/unlink.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/unlink.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/closedir.md b/docs/internals/builtins/io/closedir.md index 89234418a7..da60c70a96 100644 --- a/docs/internals/builtins/io/closedir.md +++ b/docs/internals/builtins/io/closedir.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/closedir.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/closedir.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fclose.md b/docs/internals/builtins/io/fclose.md index e7679d4f56..b5a5ea3de7 100644 --- a/docs/internals/builtins/io/fclose.md +++ b/docs/internals/builtins/io/fclose.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fclose.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fclose.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fdatasync.md b/docs/internals/builtins/io/fdatasync.md index 50949b4a96..380f99fbe0 100644 --- a/docs/internals/builtins/io/fdatasync.md +++ b/docs/internals/builtins/io/fdatasync.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fdatasync.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fdatasync.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/feof.md b/docs/internals/builtins/io/feof.md index 3c7505d11f..bb607821c3 100644 --- a/docs/internals/builtins/io/feof.md +++ b/docs/internals/builtins/io/feof.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/feof.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/feof.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fflush.md b/docs/internals/builtins/io/fflush.md index b183808bce..4f02122124 100644 --- a/docs/internals/builtins/io/fflush.md +++ b/docs/internals/builtins/io/fflush.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fflush.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fflush.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fgetc.md b/docs/internals/builtins/io/fgetc.md index f534ba7889..361eabd674 100644 --- a/docs/internals/builtins/io/fgetc.md +++ b/docs/internals/builtins/io/fgetc.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fgetc.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fgetc.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fgetcsv.md b/docs/internals/builtins/io/fgetcsv.md index 037d848e12..b4168b15c2 100644 --- a/docs/internals/builtins/io/fgetcsv.md +++ b/docs/internals/builtins/io/fgetcsv.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fgetcsv.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fgetcsv.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fgets.md b/docs/internals/builtins/io/fgets.md index dc143309ba..652c974b5a 100644 --- a/docs/internals/builtins/io/fgets.md +++ b/docs/internals/builtins/io/fgets.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fgets.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fgets.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/file.md b/docs/internals/builtins/io/file.md index 57bc2bd3d7..d159c76bb1 100644 --- a/docs/internals/builtins/io/file.md +++ b/docs/internals/builtins/io/file.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/file.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/file.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/file_get_contents.md b/docs/internals/builtins/io/file_get_contents.md index 4b69fdfd27..58a98f368e 100644 --- a/docs/internals/builtins/io/file_get_contents.md +++ b/docs/internals/builtins/io/file_get_contents.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/file_get_contents.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/file_get_contents.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/file_put_contents.md b/docs/internals/builtins/io/file_put_contents.md index 322570a1a5..af6e70c3f0 100644 --- a/docs/internals/builtins/io/file_put_contents.md +++ b/docs/internals/builtins/io/file_put_contents.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/file_put_contents.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/file_put_contents.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/flock.md b/docs/internals/builtins/io/flock.md index c8d7a6a786..3a57b9b684 100644 --- a/docs/internals/builtins/io/flock.md +++ b/docs/internals/builtins/io/flock.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/flock.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/flock.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fopen.md b/docs/internals/builtins/io/fopen.md index eb88666601..814e9e365c 100644 --- a/docs/internals/builtins/io/fopen.md +++ b/docs/internals/builtins/io/fopen.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fopen.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fopen.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fpassthru.md b/docs/internals/builtins/io/fpassthru.md index 0eed7032a9..8cb5c9bdf3 100644 --- a/docs/internals/builtins/io/fpassthru.md +++ b/docs/internals/builtins/io/fpassthru.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fpassthru.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fpassthru.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fprintf.md b/docs/internals/builtins/io/fprintf.md index 10ae07077f..b9d5069ae1 100644 --- a/docs/internals/builtins/io/fprintf.md +++ b/docs/internals/builtins/io/fprintf.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fprintf.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fprintf.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fputcsv.md b/docs/internals/builtins/io/fputcsv.md index 0989382a25..0aea5610da 100644 --- a/docs/internals/builtins/io/fputcsv.md +++ b/docs/internals/builtins/io/fputcsv.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fputcsv.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fputcsv.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fread.md b/docs/internals/builtins/io/fread.md index de7f850cbf..e758a14459 100644 --- a/docs/internals/builtins/io/fread.md +++ b/docs/internals/builtins/io/fread.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fread.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fread.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fscanf.md b/docs/internals/builtins/io/fscanf.md index d2284fe8fe..0964559533 100644 --- a/docs/internals/builtins/io/fscanf.md +++ b/docs/internals/builtins/io/fscanf.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fscanf.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fscanf.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fseek.md b/docs/internals/builtins/io/fseek.md index d83ea2d941..7aab1bd4f5 100644 --- a/docs/internals/builtins/io/fseek.md +++ b/docs/internals/builtins/io/fseek.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fseek.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fseek.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fstat.md b/docs/internals/builtins/io/fstat.md index 77db2531ad..6465de462a 100644 --- a/docs/internals/builtins/io/fstat.md +++ b/docs/internals/builtins/io/fstat.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fstat.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fstat.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fsync.md b/docs/internals/builtins/io/fsync.md index 3b04e97355..b21fb58117 100644 --- a/docs/internals/builtins/io/fsync.md +++ b/docs/internals/builtins/io/fsync.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fsync.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fsync.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ftell.md b/docs/internals/builtins/io/ftell.md index 3ed53617f0..3203ccfe1c 100644 --- a/docs/internals/builtins/io/ftell.md +++ b/docs/internals/builtins/io/ftell.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ftell.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ftell.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ftruncate.md b/docs/internals/builtins/io/ftruncate.md index 3c43448701..816a807eb4 100644 --- a/docs/internals/builtins/io/ftruncate.md +++ b/docs/internals/builtins/io/ftruncate.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ftruncate.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ftruncate.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/fwrite.md b/docs/internals/builtins/io/fwrite.md index dd5808cf7b..b3a3a45358 100644 --- a/docs/internals/builtins/io/fwrite.md +++ b/docs/internals/builtins/io/fwrite.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fwrite.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fwrite.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` @@ -39,7 +39,7 @@ sidebar: ## Signature summary ```php -function fwrite(resource $stream, string $data): int +function fwrite(resource $stream, string $data): mixed ``` ## What the type checker enforces diff --git a/docs/internals/builtins/io/gethostbyaddr.md b/docs/internals/builtins/io/gethostbyaddr.md index 7233109c78..722f5db0ae 100644 --- a/docs/internals/builtins/io/gethostbyaddr.md +++ b/docs/internals/builtins/io/gethostbyaddr.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/gethostbyaddr.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/gethostbyaddr.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/gethostbyname.md b/docs/internals/builtins/io/gethostbyname.md index 655fd1e22f..dc3a5d055f 100644 --- a/docs/internals/builtins/io/gethostbyname.md +++ b/docs/internals/builtins/io/gethostbyname.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/gethostbyname.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/gethostbyname.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/gethostname.md b/docs/internals/builtins/io/gethostname.md index 7f0826f386..9767015033 100644 --- a/docs/internals/builtins/io/gethostname.md +++ b/docs/internals/builtins/io/gethostname.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/gethostname.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/gethostname.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/getprotobyname.md b/docs/internals/builtins/io/getprotobyname.md index 44f76ba715..d136d78719 100644 --- a/docs/internals/builtins/io/getprotobyname.md +++ b/docs/internals/builtins/io/getprotobyname.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/getprotobyname.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/getprotobyname.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/getprotobynumber.md b/docs/internals/builtins/io/getprotobynumber.md index b85c084bea..8b70843842 100644 --- a/docs/internals/builtins/io/getprotobynumber.md +++ b/docs/internals/builtins/io/getprotobynumber.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/getprotobynumber.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/getprotobynumber.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/getservbyname.md b/docs/internals/builtins/io/getservbyname.md index ec04ce51c1..3033fcd358 100644 --- a/docs/internals/builtins/io/getservbyname.md +++ b/docs/internals/builtins/io/getservbyname.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/getservbyname.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/getservbyname.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/getservbyport.md b/docs/internals/builtins/io/getservbyport.md index f3e22f3b48..16b15b071d 100644 --- a/docs/internals/builtins/io/getservbyport.md +++ b/docs/internals/builtins/io/getservbyport.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/getservbyport.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/getservbyport.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/hash_file.md b/docs/internals/builtins/io/hash_file.md index 14205b0694..e8ac3a0830 100644 --- a/docs/internals/builtins/io/hash_file.md +++ b/docs/internals/builtins/io/hash_file.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/hash_file.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/hash_file.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_clean.md b/docs/internals/builtins/io/ob_clean.md index 31f3ade572..e61edcdfc0 100644 --- a/docs/internals/builtins/io/ob_clean.md +++ b/docs/internals/builtins/io/ob_clean.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_clean.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_clean.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_end_clean.md b/docs/internals/builtins/io/ob_end_clean.md index a5a1dc385c..0417f05c80 100644 --- a/docs/internals/builtins/io/ob_end_clean.md +++ b/docs/internals/builtins/io/ob_end_clean.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_end_clean.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_end_clean.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_end_flush.md b/docs/internals/builtins/io/ob_end_flush.md index 35f1a0be9b..e32937a6a8 100644 --- a/docs/internals/builtins/io/ob_end_flush.md +++ b/docs/internals/builtins/io/ob_end_flush.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_end_flush.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_end_flush.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_flush.md b/docs/internals/builtins/io/ob_flush.md index f2c9179908..7debb7b9b7 100644 --- a/docs/internals/builtins/io/ob_flush.md +++ b/docs/internals/builtins/io/ob_flush.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_flush.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_flush.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_get_clean.md b/docs/internals/builtins/io/ob_get_clean.md index 19b657e0aa..d11c8d199f 100644 --- a/docs/internals/builtins/io/ob_get_clean.md +++ b/docs/internals/builtins/io/ob_get_clean.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_get_clean.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_get_clean.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_get_contents.md b/docs/internals/builtins/io/ob_get_contents.md index 46452e07cb..cab3744f49 100644 --- a/docs/internals/builtins/io/ob_get_contents.md +++ b/docs/internals/builtins/io/ob_get_contents.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_get_contents.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_get_contents.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_get_flush.md b/docs/internals/builtins/io/ob_get_flush.md index 34f4bd1762..69bec4fd14 100644 --- a/docs/internals/builtins/io/ob_get_flush.md +++ b/docs/internals/builtins/io/ob_get_flush.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_get_flush.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_get_flush.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_get_length.md b/docs/internals/builtins/io/ob_get_length.md index 0cd50fb0ce..c3cfbd1371 100644 --- a/docs/internals/builtins/io/ob_get_length.md +++ b/docs/internals/builtins/io/ob_get_length.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_get_length.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_get_length.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_get_level.md b/docs/internals/builtins/io/ob_get_level.md index 074022ecd2..8d1a9ff45c 100644 --- a/docs/internals/builtins/io/ob_get_level.md +++ b/docs/internals/builtins/io/ob_get_level.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_get_level.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_get_level.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_get_status.md b/docs/internals/builtins/io/ob_get_status.md index 5889be0e4b..96553ae1a1 100644 --- a/docs/internals/builtins/io/ob_get_status.md +++ b/docs/internals/builtins/io/ob_get_status.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_get_status.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_get_status.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_implicit_flush.md b/docs/internals/builtins/io/ob_implicit_flush.md index f9ecfb9df3..040ab8c4e4 100644 --- a/docs/internals/builtins/io/ob_implicit_flush.md +++ b/docs/internals/builtins/io/ob_implicit_flush.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_implicit_flush.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_implicit_flush.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_list_handlers.md b/docs/internals/builtins/io/ob_list_handlers.md index 70bc0d9e08..5af0c5b065 100644 --- a/docs/internals/builtins/io/ob_list_handlers.md +++ b/docs/internals/builtins/io/ob_list_handlers.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_list_handlers.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_list_handlers.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/ob_start.md b/docs/internals/builtins/io/ob_start.md index b0e0a3a6a6..9a67971ce0 100644 --- a/docs/internals/builtins/io/ob_start.md +++ b/docs/internals/builtins/io/ob_start.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/ob_start.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/ob_start.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/opendir.md b/docs/internals/builtins/io/opendir.md index ffe97a2451..0fb48b969f 100644 --- a/docs/internals/builtins/io/opendir.md +++ b/docs/internals/builtins/io/opendir.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/opendir.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/opendir.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/readdir.md b/docs/internals/builtins/io/readdir.md index 674244f8a6..4d8867a9fe 100644 --- a/docs/internals/builtins/io/readdir.md +++ b/docs/internals/builtins/io/readdir.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/readdir.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/readdir.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/rewind.md b/docs/internals/builtins/io/rewind.md index 7773d00050..2d18b12c03 100644 --- a/docs/internals/builtins/io/rewind.md +++ b/docs/internals/builtins/io/rewind.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/rewind.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/rewind.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/rewinddir.md b/docs/internals/builtins/io/rewinddir.md index 018c31e517..ba138f8cdc 100644 --- a/docs/internals/builtins/io/rewinddir.md +++ b/docs/internals/builtins/io/rewinddir.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/rewinddir.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/rewinddir.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_bucket_make_writeable.md b/docs/internals/builtins/io/stream_bucket_make_writeable.md index 1cb2224b1b..00705bd73d 100644 --- a/docs/internals/builtins/io/stream_bucket_make_writeable.md +++ b/docs/internals/builtins/io/stream_bucket_make_writeable.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_bucket_make_writeable.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_bucket_make_writeable.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_bucket_new.md b/docs/internals/builtins/io/stream_bucket_new.md index 51cfc6010b..9811211575 100644 --- a/docs/internals/builtins/io/stream_bucket_new.md +++ b/docs/internals/builtins/io/stream_bucket_new.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_bucket_new.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_bucket_new.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_context_create.md b/docs/internals/builtins/io/stream_context_create.md index 434eab349d..a4de1267b2 100644 --- a/docs/internals/builtins/io/stream_context_create.md +++ b/docs/internals/builtins/io/stream_context_create.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_context_create.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_context_create.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_context_get_default.md b/docs/internals/builtins/io/stream_context_get_default.md index c85376820b..f0202f1265 100644 --- a/docs/internals/builtins/io/stream_context_get_default.md +++ b/docs/internals/builtins/io/stream_context_get_default.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_context_get_default.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_context_get_default.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_context_get_options.md b/docs/internals/builtins/io/stream_context_get_options.md index bb0b9a95f1..6ee8761b93 100644 --- a/docs/internals/builtins/io/stream_context_get_options.md +++ b/docs/internals/builtins/io/stream_context_get_options.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_context_get_options.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_context_get_options.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_context_get_params.md b/docs/internals/builtins/io/stream_context_get_params.md index b4f42b3b9a..521790c528 100644 --- a/docs/internals/builtins/io/stream_context_get_params.md +++ b/docs/internals/builtins/io/stream_context_get_params.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_context_get_params.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_context_get_params.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_context_set_default.md b/docs/internals/builtins/io/stream_context_set_default.md index e4a0170ae2..b0ede765c0 100644 --- a/docs/internals/builtins/io/stream_context_set_default.md +++ b/docs/internals/builtins/io/stream_context_set_default.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_context_set_default.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_context_set_default.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_context_set_option.md b/docs/internals/builtins/io/stream_context_set_option.md index 1ac261f401..fcfed79925 100644 --- a/docs/internals/builtins/io/stream_context_set_option.md +++ b/docs/internals/builtins/io/stream_context_set_option.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_context_set_option.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_context_set_option.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_context_set_params.md b/docs/internals/builtins/io/stream_context_set_params.md index 164aba6dfe..e73a7fa983 100644 --- a/docs/internals/builtins/io/stream_context_set_params.md +++ b/docs/internals/builtins/io/stream_context_set_params.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_context_set_params.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_context_set_params.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_copy_to_stream.md b/docs/internals/builtins/io/stream_copy_to_stream.md index 9c2ef59ba8..3dc073435a 100644 --- a/docs/internals/builtins/io/stream_copy_to_stream.md +++ b/docs/internals/builtins/io/stream_copy_to_stream.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_copy_to_stream.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_copy_to_stream.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_filter_register.md b/docs/internals/builtins/io/stream_filter_register.md index 8e3c6896b3..c2c13c43c1 100644 --- a/docs/internals/builtins/io/stream_filter_register.md +++ b/docs/internals/builtins/io/stream_filter_register.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_filter_register.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_filter_register.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_filter_remove.md b/docs/internals/builtins/io/stream_filter_remove.md index 21cd7b51d2..651aca90b1 100644 --- a/docs/internals/builtins/io/stream_filter_remove.md +++ b/docs/internals/builtins/io/stream_filter_remove.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_filter_remove.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_filter_remove.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_get_contents.md b/docs/internals/builtins/io/stream_get_contents.md index ab9fe349b4..8780001e4a 100644 --- a/docs/internals/builtins/io/stream_get_contents.md +++ b/docs/internals/builtins/io/stream_get_contents.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_get_contents.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_get_contents.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_get_filters.md b/docs/internals/builtins/io/stream_get_filters.md index 7e2bb5cde2..c4df05f70e 100644 --- a/docs/internals/builtins/io/stream_get_filters.md +++ b/docs/internals/builtins/io/stream_get_filters.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_get_filters.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_get_filters.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_get_line.md b/docs/internals/builtins/io/stream_get_line.md index ade5996358..31854ab9ad 100644 --- a/docs/internals/builtins/io/stream_get_line.md +++ b/docs/internals/builtins/io/stream_get_line.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_get_line.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_get_line.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_get_meta_data.md b/docs/internals/builtins/io/stream_get_meta_data.md index 89214df4b0..8177a83754 100644 --- a/docs/internals/builtins/io/stream_get_meta_data.md +++ b/docs/internals/builtins/io/stream_get_meta_data.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_get_meta_data.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_get_meta_data.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_get_transports.md b/docs/internals/builtins/io/stream_get_transports.md index 85db7d0758..4f5af37508 100644 --- a/docs/internals/builtins/io/stream_get_transports.md +++ b/docs/internals/builtins/io/stream_get_transports.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_get_transports.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_get_transports.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_get_wrappers.md b/docs/internals/builtins/io/stream_get_wrappers.md index 47b3566dd2..a8d852ce43 100644 --- a/docs/internals/builtins/io/stream_get_wrappers.md +++ b/docs/internals/builtins/io/stream_get_wrappers.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_get_wrappers.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_get_wrappers.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_is_local.md b/docs/internals/builtins/io/stream_is_local.md index d2ea33e907..5f918a045c 100644 --- a/docs/internals/builtins/io/stream_is_local.md +++ b/docs/internals/builtins/io/stream_is_local.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_is_local.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_is_local.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_isatty.md b/docs/internals/builtins/io/stream_isatty.md index f952f055ef..e8c01b08ac 100644 --- a/docs/internals/builtins/io/stream_isatty.md +++ b/docs/internals/builtins/io/stream_isatty.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_isatty.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_isatty.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_resolve_include_path.md b/docs/internals/builtins/io/stream_resolve_include_path.md index db2cccf200..6350181b0c 100644 --- a/docs/internals/builtins/io/stream_resolve_include_path.md +++ b/docs/internals/builtins/io/stream_resolve_include_path.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_resolve_include_path.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_resolve_include_path.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_select.md b/docs/internals/builtins/io/stream_select.md index 8c8b274918..d813bd146b 100644 --- a/docs/internals/builtins/io/stream_select.md +++ b/docs/internals/builtins/io/stream_select.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_select.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_select.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_set_blocking.md b/docs/internals/builtins/io/stream_set_blocking.md index 303150d708..79c229d5eb 100644 --- a/docs/internals/builtins/io/stream_set_blocking.md +++ b/docs/internals/builtins/io/stream_set_blocking.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_set_blocking.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_set_blocking.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_set_chunk_size.md b/docs/internals/builtins/io/stream_set_chunk_size.md index d1180c6d3a..0847673e91 100644 --- a/docs/internals/builtins/io/stream_set_chunk_size.md +++ b/docs/internals/builtins/io/stream_set_chunk_size.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_set_chunk_size.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_set_chunk_size.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_set_read_buffer.md b/docs/internals/builtins/io/stream_set_read_buffer.md index bbd112184c..4fcbb9d33c 100644 --- a/docs/internals/builtins/io/stream_set_read_buffer.md +++ b/docs/internals/builtins/io/stream_set_read_buffer.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_set_read_buffer.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_set_read_buffer.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_set_timeout.md b/docs/internals/builtins/io/stream_set_timeout.md index b47a13d7f0..5a824aefa0 100644 --- a/docs/internals/builtins/io/stream_set_timeout.md +++ b/docs/internals/builtins/io/stream_set_timeout.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_set_timeout.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_set_timeout.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_set_write_buffer.md b/docs/internals/builtins/io/stream_set_write_buffer.md index c18b669f9a..77050fbdf5 100644 --- a/docs/internals/builtins/io/stream_set_write_buffer.md +++ b/docs/internals/builtins/io/stream_set_write_buffer.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_set_write_buffer.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_set_write_buffer.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_socket_accept.md b/docs/internals/builtins/io/stream_socket_accept.md index fbc4496d47..50391d04cf 100644 --- a/docs/internals/builtins/io/stream_socket_accept.md +++ b/docs/internals/builtins/io/stream_socket_accept.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_socket_accept.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_socket_accept.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_socket_client.md b/docs/internals/builtins/io/stream_socket_client.md index f760c86981..cf89fc5af3 100644 --- a/docs/internals/builtins/io/stream_socket_client.md +++ b/docs/internals/builtins/io/stream_socket_client.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_socket_client.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_socket_client.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_socket_enable_crypto.md b/docs/internals/builtins/io/stream_socket_enable_crypto.md index 7e446a9d87..df1d82ad39 100644 --- a/docs/internals/builtins/io/stream_socket_enable_crypto.md +++ b/docs/internals/builtins/io/stream_socket_enable_crypto.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_socket_enable_crypto.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_socket_enable_crypto.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_socket_get_name.md b/docs/internals/builtins/io/stream_socket_get_name.md index 1cab54ef6e..442c4324db 100644 --- a/docs/internals/builtins/io/stream_socket_get_name.md +++ b/docs/internals/builtins/io/stream_socket_get_name.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_socket_get_name.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_socket_get_name.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_socket_pair.md b/docs/internals/builtins/io/stream_socket_pair.md index 2770a14914..b2339bab34 100644 --- a/docs/internals/builtins/io/stream_socket_pair.md +++ b/docs/internals/builtins/io/stream_socket_pair.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_socket_pair.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_socket_pair.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_socket_recvfrom.md b/docs/internals/builtins/io/stream_socket_recvfrom.md index 2c0379bf04..67756ef5e0 100644 --- a/docs/internals/builtins/io/stream_socket_recvfrom.md +++ b/docs/internals/builtins/io/stream_socket_recvfrom.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_socket_recvfrom.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_socket_recvfrom.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_socket_sendto.md b/docs/internals/builtins/io/stream_socket_sendto.md index 5d90e85768..99133f9a8e 100644 --- a/docs/internals/builtins/io/stream_socket_sendto.md +++ b/docs/internals/builtins/io/stream_socket_sendto.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_socket_sendto.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_socket_sendto.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_socket_server.md b/docs/internals/builtins/io/stream_socket_server.md index b437a9bc09..7d97df0aee 100644 --- a/docs/internals/builtins/io/stream_socket_server.md +++ b/docs/internals/builtins/io/stream_socket_server.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_socket_server.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_socket_server.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_socket_shutdown.md b/docs/internals/builtins/io/stream_socket_shutdown.md index a8e91d570a..445c9f3361 100644 --- a/docs/internals/builtins/io/stream_socket_shutdown.md +++ b/docs/internals/builtins/io/stream_socket_shutdown.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_socket_shutdown.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_socket_shutdown.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_supports_lock.md b/docs/internals/builtins/io/stream_supports_lock.md index 24914086ca..c3546094ef 100644 --- a/docs/internals/builtins/io/stream_supports_lock.md +++ b/docs/internals/builtins/io/stream_supports_lock.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_supports_lock.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_supports_lock.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_wrapper_register.md b/docs/internals/builtins/io/stream_wrapper_register.md index 034f3c477f..40e05659ce 100644 --- a/docs/internals/builtins/io/stream_wrapper_register.md +++ b/docs/internals/builtins/io/stream_wrapper_register.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_wrapper_register.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_wrapper_register.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_wrapper_restore.md b/docs/internals/builtins/io/stream_wrapper_restore.md index dc3049bafe..05a47e9338 100644 --- a/docs/internals/builtins/io/stream_wrapper_restore.md +++ b/docs/internals/builtins/io/stream_wrapper_restore.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_wrapper_restore.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_wrapper_restore.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/stream_wrapper_unregister.md b/docs/internals/builtins/io/stream_wrapper_unregister.md index d6e256880d..927ecf3e4b 100644 --- a/docs/internals/builtins/io/stream_wrapper_unregister.md +++ b/docs/internals/builtins/io/stream_wrapper_unregister.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_wrapper_unregister.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_wrapper_unregister.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/io/vfprintf.md b/docs/internals/builtins/io/vfprintf.md index 85e7005d47..af61e9760c 100644 --- a/docs/internals/builtins/io/vfprintf.md +++ b/docs/internals/builtins/io/vfprintf.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/vfprintf.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/vfprintf.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/json/json_decode.md b/docs/internals/builtins/json/json_decode.md index 58b8688b69..8f22ce9301 100644 --- a/docs/internals/builtins/json/json_decode.md +++ b/docs/internals/builtins/json/json_decode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/json_decode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/json_decode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/json/json_encode.md b/docs/internals/builtins/json/json_encode.md index 33098c1e76..ee982d0ab1 100644 --- a/docs/internals/builtins/json/json_encode.md +++ b/docs/internals/builtins/json/json_encode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/json_encode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/json_encode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/json/json_last_error.md b/docs/internals/builtins/json/json_last_error.md index 8cefcad314..7d159e3311 100644 --- a/docs/internals/builtins/json/json_last_error.md +++ b/docs/internals/builtins/json/json_last_error.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/json_last_error.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/json_last_error.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/json/json_last_error_msg.md b/docs/internals/builtins/json/json_last_error_msg.md index cf215019c9..835737b286 100644 --- a/docs/internals/builtins/json/json_last_error_msg.md +++ b/docs/internals/builtins/json/json_last_error_msg.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/json_last_error_msg.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/json_last_error_msg.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/json/json_validate.md b/docs/internals/builtins/json/json_validate.md index b44622342e..32cd606d28 100644 --- a/docs/internals/builtins/json/json_validate.md +++ b/docs/internals/builtins/json/json_validate.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/json_validate.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/json_validate.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/abs.md b/docs/internals/builtins/math/abs.md index bfb4302953..d586168eff 100644 --- a/docs/internals/builtins/math/abs.md +++ b/docs/internals/builtins/math/abs.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/abs.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/abs.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/acos.md b/docs/internals/builtins/math/acos.md index ddcf26a7de..a1a5b47dc5 100644 --- a/docs/internals/builtins/math/acos.md +++ b/docs/internals/builtins/math/acos.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/acos.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/acos.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/asin.md b/docs/internals/builtins/math/asin.md index 1c6f974e70..aca374a0b2 100644 --- a/docs/internals/builtins/math/asin.md +++ b/docs/internals/builtins/math/asin.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/asin.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/asin.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/atan.md b/docs/internals/builtins/math/atan.md index ee89d401d9..93025f34b1 100644 --- a/docs/internals/builtins/math/atan.md +++ b/docs/internals/builtins/math/atan.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/atan.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/atan.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/atan2.md b/docs/internals/builtins/math/atan2.md index a55e711b41..ccf1873be2 100644 --- a/docs/internals/builtins/math/atan2.md +++ b/docs/internals/builtins/math/atan2.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/atan2.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/atan2.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/ceil.md b/docs/internals/builtins/math/ceil.md index abbc303cc3..6e2ae8c48b 100644 --- a/docs/internals/builtins/math/ceil.md +++ b/docs/internals/builtins/math/ceil.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/ceil.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/ceil.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/clamp.md b/docs/internals/builtins/math/clamp.md index 32a95ad77b..864d5ceeba 100644 --- a/docs/internals/builtins/math/clamp.md +++ b/docs/internals/builtins/math/clamp.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/clamp.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/clamp.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/cos.md b/docs/internals/builtins/math/cos.md index f1e6396de1..efdeb267a5 100644 --- a/docs/internals/builtins/math/cos.md +++ b/docs/internals/builtins/math/cos.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/cos.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/cos.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/cosh.md b/docs/internals/builtins/math/cosh.md index 1d6a9bed8c..4abd14de4f 100644 --- a/docs/internals/builtins/math/cosh.md +++ b/docs/internals/builtins/math/cosh.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/cosh.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/cosh.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/deg2rad.md b/docs/internals/builtins/math/deg2rad.md index 23e234f9c0..75857e8486 100644 --- a/docs/internals/builtins/math/deg2rad.md +++ b/docs/internals/builtins/math/deg2rad.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/deg2rad.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/deg2rad.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/exp.md b/docs/internals/builtins/math/exp.md index ffda6f112c..046026a40b 100644 --- a/docs/internals/builtins/math/exp.md +++ b/docs/internals/builtins/math/exp.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/exp.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/exp.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/fdiv.md b/docs/internals/builtins/math/fdiv.md index 07f9117d48..2d8a4f5b4e 100644 --- a/docs/internals/builtins/math/fdiv.md +++ b/docs/internals/builtins/math/fdiv.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/fdiv.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/fdiv.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/floor.md b/docs/internals/builtins/math/floor.md index 663ed6e4a5..659b8e8972 100644 --- a/docs/internals/builtins/math/floor.md +++ b/docs/internals/builtins/math/floor.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/floor.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/floor.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/fmod.md b/docs/internals/builtins/math/fmod.md index 3d1893c61e..fa8548bceb 100644 --- a/docs/internals/builtins/math/fmod.md +++ b/docs/internals/builtins/math/fmod.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/fmod.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/fmod.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/hypot.md b/docs/internals/builtins/math/hypot.md index 798a4b3aeb..92be0076a0 100644 --- a/docs/internals/builtins/math/hypot.md +++ b/docs/internals/builtins/math/hypot.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/hypot.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/hypot.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/intdiv.md b/docs/internals/builtins/math/intdiv.md index 8cbf5c2863..ac85b449c6 100644 --- a/docs/internals/builtins/math/intdiv.md +++ b/docs/internals/builtins/math/intdiv.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/intdiv.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/intdiv.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/is_finite.md b/docs/internals/builtins/math/is_finite.md index 8dbea06405..58a70b0fd7 100644 --- a/docs/internals/builtins/math/is_finite.md +++ b/docs/internals/builtins/math/is_finite.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_finite.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_finite.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/is_infinite.md b/docs/internals/builtins/math/is_infinite.md index 980f391c57..2b658dd74a 100644 --- a/docs/internals/builtins/math/is_infinite.md +++ b/docs/internals/builtins/math/is_infinite.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_infinite.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_infinite.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/is_nan.md b/docs/internals/builtins/math/is_nan.md index b0fcf2004d..f8d7b78917 100644 --- a/docs/internals/builtins/math/is_nan.md +++ b/docs/internals/builtins/math/is_nan.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_nan.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_nan.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/log.md b/docs/internals/builtins/math/log.md index 92a61552a0..05a6464290 100644 --- a/docs/internals/builtins/math/log.md +++ b/docs/internals/builtins/math/log.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/log.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/log.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/log10.md b/docs/internals/builtins/math/log10.md index 1b1b9726ac..a78a6a97ca 100644 --- a/docs/internals/builtins/math/log10.md +++ b/docs/internals/builtins/math/log10.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/log10.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/log10.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/log2.md b/docs/internals/builtins/math/log2.md index 962714fc4e..34cb11c67c 100644 --- a/docs/internals/builtins/math/log2.md +++ b/docs/internals/builtins/math/log2.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/log2.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/log2.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/max.md b/docs/internals/builtins/math/max.md index 32b277f179..ac60b35d3f 100644 --- a/docs/internals/builtins/math/max.md +++ b/docs/internals/builtins/math/max.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/max.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/max.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/min.md b/docs/internals/builtins/math/min.md index cda5fc601c..5b0f8b1ac2 100644 --- a/docs/internals/builtins/math/min.md +++ b/docs/internals/builtins/math/min.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/min.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/min.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/mt_rand.md b/docs/internals/builtins/math/mt_rand.md index c1b9b27636..e6aa9c2bfd 100644 --- a/docs/internals/builtins/math/mt_rand.md +++ b/docs/internals/builtins/math/mt_rand.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/mt_rand.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/mt_rand.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/pi.md b/docs/internals/builtins/math/pi.md index 395096715f..e9ebcd8f89 100644 --- a/docs/internals/builtins/math/pi.md +++ b/docs/internals/builtins/math/pi.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/pi.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/pi.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/pow.md b/docs/internals/builtins/math/pow.md index 922a489909..d448dbea4c 100644 --- a/docs/internals/builtins/math/pow.md +++ b/docs/internals/builtins/math/pow.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/pow.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/pow.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/rad2deg.md b/docs/internals/builtins/math/rad2deg.md index b2d487e446..545f01b13f 100644 --- a/docs/internals/builtins/math/rad2deg.md +++ b/docs/internals/builtins/math/rad2deg.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/rad2deg.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/rad2deg.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/rand.md b/docs/internals/builtins/math/rand.md index fc6a82cce5..2148957917 100644 --- a/docs/internals/builtins/math/rand.md +++ b/docs/internals/builtins/math/rand.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/rand.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/rand.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/random_int.md b/docs/internals/builtins/math/random_int.md index 881e64f2d7..c86741daef 100644 --- a/docs/internals/builtins/math/random_int.md +++ b/docs/internals/builtins/math/random_int.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/random_int.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/random_int.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/round.md b/docs/internals/builtins/math/round.md index ba93b95cdf..09dcf188c9 100644 --- a/docs/internals/builtins/math/round.md +++ b/docs/internals/builtins/math/round.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/round.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/round.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/sin.md b/docs/internals/builtins/math/sin.md index a8d5a4068b..0d32e8761e 100644 --- a/docs/internals/builtins/math/sin.md +++ b/docs/internals/builtins/math/sin.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/sin.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/sin.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/sinh.md b/docs/internals/builtins/math/sinh.md index 61cdd2961f..3fccece5c0 100644 --- a/docs/internals/builtins/math/sinh.md +++ b/docs/internals/builtins/math/sinh.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/sinh.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/sinh.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/sqrt.md b/docs/internals/builtins/math/sqrt.md index 9c81f407ea..d5fe1451e6 100644 --- a/docs/internals/builtins/math/sqrt.md +++ b/docs/internals/builtins/math/sqrt.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/sqrt.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/sqrt.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/tan.md b/docs/internals/builtins/math/tan.md index 48bc0891e3..333b5cf475 100644 --- a/docs/internals/builtins/math/tan.md +++ b/docs/internals/builtins/math/tan.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/tan.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/tan.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/math/tanh.md b/docs/internals/builtins/math/tanh.md index c4317b46b6..6734e0afbf 100644 --- a/docs/internals/builtins/math/tanh.md +++ b/docs/internals/builtins/math/tanh.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/math/tanh.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/math/tanh.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/define.md b/docs/internals/builtins/misc/define.md index 89fcf45294..f61bbd8f70 100644 --- a/docs/internals/builtins/misc/define.md +++ b/docs/internals/builtins/misc/define.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/define.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/define.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/defined.md b/docs/internals/builtins/misc/defined.md index 25e48061a0..52f198cd62 100644 --- a/docs/internals/builtins/misc/defined.md +++ b/docs/internals/builtins/misc/defined.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/defined.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/defined.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/extension_loaded.md b/docs/internals/builtins/misc/extension_loaded.md index 139a284a75..cd9c3bb13d 100644 --- a/docs/internals/builtins/misc/extension_loaded.md +++ b/docs/internals/builtins/misc/extension_loaded.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/extension_loaded.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/extension_loaded.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/get_loaded_extensions.md b/docs/internals/builtins/misc/get_loaded_extensions.md index 9b38c7b3eb..97ad2f8ae8 100644 --- a/docs/internals/builtins/misc/get_loaded_extensions.md +++ b/docs/internals/builtins/misc/get_loaded_extensions.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/get_loaded_extensions.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/get_loaded_extensions.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/header.md b/docs/internals/builtins/misc/header.md index aeebc87eed..dc0bcba995 100644 --- a/docs/internals/builtins/misc/header.md +++ b/docs/internals/builtins/misc/header.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/header.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/header.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/http_response_code.md b/docs/internals/builtins/misc/http_response_code.md index 5bd91d64db..9e8465088a 100644 --- a/docs/internals/builtins/misc/http_response_code.md +++ b/docs/internals/builtins/misc/http_response_code.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/http_response_code.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/http_response_code.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/php_uname.md b/docs/internals/builtins/misc/php_uname.md index cee0f12145..b6d46f26f4 100644 --- a/docs/internals/builtins/misc/php_uname.md +++ b/docs/internals/builtins/misc/php_uname.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/php_uname.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/php_uname.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/phpversion.md b/docs/internals/builtins/misc/phpversion.md index fc90b46c26..5a58c5815f 100644 --- a/docs/internals/builtins/misc/phpversion.md +++ b/docs/internals/builtins/misc/phpversion.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/phpversion.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/phpversion.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/print_r.md b/docs/internals/builtins/misc/print_r.md index b1adb88643..dabbbf8544 100644 --- a/docs/internals/builtins/misc/print_r.md +++ b/docs/internals/builtins/misc/print_r.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/print_r.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/print_r.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/serialize.md b/docs/internals/builtins/misc/serialize.md index 892909df68..88d7dcc3cd 100644 --- a/docs/internals/builtins/misc/serialize.md +++ b/docs/internals/builtins/misc/serialize.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/serialize.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/serialize.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/unserialize.md b/docs/internals/builtins/misc/unserialize.md index dbf9a5f3ef..deab79620d 100644 --- a/docs/internals/builtins/misc/unserialize.md +++ b/docs/internals/builtins/misc/unserialize.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/unserialize.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/unserialize.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/misc/var_dump.md b/docs/internals/builtins/misc/var_dump.md index ae919ae67e..d0f908f1c8 100644 --- a/docs/internals/builtins/misc/var_dump.md +++ b/docs/internals/builtins/misc/var_dump.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/var_dump.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/var_dump.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr.md b/docs/internals/builtins/pointer/ptr.md index bea55c0627..6f3270fc98 100644 --- a/docs/internals/builtins/pointer/ptr.md +++ b/docs/internals/builtins/pointer/ptr.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_get.md b/docs/internals/builtins/pointer/ptr_get.md index b8e7ce2bd5..1888bd648c 100644 --- a/docs/internals/builtins/pointer/ptr_get.md +++ b/docs/internals/builtins/pointer/ptr_get.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_get.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_get.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_is_null.md b/docs/internals/builtins/pointer/ptr_is_null.md index ea40cc3c03..e50b104764 100644 --- a/docs/internals/builtins/pointer/ptr_is_null.md +++ b/docs/internals/builtins/pointer/ptr_is_null.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_is_null.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_is_null.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_null.md b/docs/internals/builtins/pointer/ptr_null.md index e4c3461ee5..4f358e53a9 100644 --- a/docs/internals/builtins/pointer/ptr_null.md +++ b/docs/internals/builtins/pointer/ptr_null.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_null.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_null.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_offset.md b/docs/internals/builtins/pointer/ptr_offset.md index 1d489c9889..4d287eece4 100644 --- a/docs/internals/builtins/pointer/ptr_offset.md +++ b/docs/internals/builtins/pointer/ptr_offset.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_offset.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_offset.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_read16.md b/docs/internals/builtins/pointer/ptr_read16.md index 687e36185e..da1edc0192 100644 --- a/docs/internals/builtins/pointer/ptr_read16.md +++ b/docs/internals/builtins/pointer/ptr_read16.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_read16.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_read16.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_read32.md b/docs/internals/builtins/pointer/ptr_read32.md index d0ea79bb86..7acbb14207 100644 --- a/docs/internals/builtins/pointer/ptr_read32.md +++ b/docs/internals/builtins/pointer/ptr_read32.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_read32.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_read32.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_read8.md b/docs/internals/builtins/pointer/ptr_read8.md index 69b0ecd3b0..5289c2b8d9 100644 --- a/docs/internals/builtins/pointer/ptr_read8.md +++ b/docs/internals/builtins/pointer/ptr_read8.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_read8.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_read8.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_read_string.md b/docs/internals/builtins/pointer/ptr_read_string.md index 18aae3921f..882b1679e9 100644 --- a/docs/internals/builtins/pointer/ptr_read_string.md +++ b/docs/internals/builtins/pointer/ptr_read_string.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_read_string.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_read_string.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_set.md b/docs/internals/builtins/pointer/ptr_set.md index 811f97c265..2a891b4da8 100644 --- a/docs/internals/builtins/pointer/ptr_set.md +++ b/docs/internals/builtins/pointer/ptr_set.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_set.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_set.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_sizeof.md b/docs/internals/builtins/pointer/ptr_sizeof.md index 5665a30eb6..98ede4ef02 100644 --- a/docs/internals/builtins/pointer/ptr_sizeof.md +++ b/docs/internals/builtins/pointer/ptr_sizeof.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_sizeof.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_sizeof.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_write16.md b/docs/internals/builtins/pointer/ptr_write16.md index afdd656321..ee9286f041 100644 --- a/docs/internals/builtins/pointer/ptr_write16.md +++ b/docs/internals/builtins/pointer/ptr_write16.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_write16.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_write16.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_write32.md b/docs/internals/builtins/pointer/ptr_write32.md index eb4cbc96ca..9df31cb4d2 100644 --- a/docs/internals/builtins/pointer/ptr_write32.md +++ b/docs/internals/builtins/pointer/ptr_write32.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_write32.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_write32.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_write8.md b/docs/internals/builtins/pointer/ptr_write8.md index 95c3d5b4a4..0e3b65d146 100644 --- a/docs/internals/builtins/pointer/ptr_write8.md +++ b/docs/internals/builtins/pointer/ptr_write8.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_write8.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_write8.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/ptr_write_string.md b/docs/internals/builtins/pointer/ptr_write_string.md index 6800203c10..57100ad297 100644 --- a/docs/internals/builtins/pointer/ptr_write_string.md +++ b/docs/internals/builtins/pointer/ptr_write_string.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/ptr_write_string.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/ptr_write_string.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/zval_free.md b/docs/internals/builtins/pointer/zval_free.md index d1a0a219ae..cbba58a499 100644 --- a/docs/internals/builtins/pointer/zval_free.md +++ b/docs/internals/builtins/pointer/zval_free.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/zval_free.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/zval_free.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/zval_pack.md b/docs/internals/builtins/pointer/zval_pack.md index b17de70195..360ad50c01 100644 --- a/docs/internals/builtins/pointer/zval_pack.md +++ b/docs/internals/builtins/pointer/zval_pack.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/zval_pack.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/zval_pack.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/zval_type.md b/docs/internals/builtins/pointer/zval_type.md index ba70852d67..0a93594394 100644 --- a/docs/internals/builtins/pointer/zval_type.md +++ b/docs/internals/builtins/pointer/zval_type.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/zval_type.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/zval_type.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/pointer/zval_unpack.md b/docs/internals/builtins/pointer/zval_unpack.md index b989486795..db9020fd04 100644 --- a/docs/internals/builtins/pointer/zval_unpack.md +++ b/docs/internals/builtins/pointer/zval_unpack.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/pointers/zval_unpack.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/pointers/zval_unpack.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/process/exec.md b/docs/internals/builtins/process/exec.md index 0f36a4f83a..e96a6a9a6d 100644 --- a/docs/internals/builtins/process/exec.md +++ b/docs/internals/builtins/process/exec.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/exec.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/exec.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/process/passthru.md b/docs/internals/builtins/process/passthru.md index 06ca41877e..e855a93516 100644 --- a/docs/internals/builtins/process/passthru.md +++ b/docs/internals/builtins/process/passthru.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/passthru.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/passthru.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/process/pclose.md b/docs/internals/builtins/process/pclose.md index eff52bf013..40a16e8956 100644 --- a/docs/internals/builtins/process/pclose.md +++ b/docs/internals/builtins/process/pclose.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/pclose.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/pclose.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/process/popen.md b/docs/internals/builtins/process/popen.md index a00a199525..df3164ba4f 100644 --- a/docs/internals/builtins/process/popen.md +++ b/docs/internals/builtins/process/popen.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/popen.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/popen.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/process/readline.md b/docs/internals/builtins/process/readline.md index adbd97dc02..b05bf9f980 100644 --- a/docs/internals/builtins/process/readline.md +++ b/docs/internals/builtins/process/readline.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/readline.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/readline.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/process/shell_exec.md b/docs/internals/builtins/process/shell_exec.md index 8ec9e59e2e..2a438e48f9 100644 --- a/docs/internals/builtins/process/shell_exec.md +++ b/docs/internals/builtins/process/shell_exec.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/shell_exec.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/shell_exec.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/process/sleep.md b/docs/internals/builtins/process/sleep.md index 107ec7ff55..4eeb8660b6 100644 --- a/docs/internals/builtins/process/sleep.md +++ b/docs/internals/builtins/process/sleep.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/sleep.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/sleep.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/process/system.md b/docs/internals/builtins/process/system.md index afa509b659..979aa6e212 100644 --- a/docs/internals/builtins/process/system.md +++ b/docs/internals/builtins/process/system.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/system.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/system.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/process/usleep.md b/docs/internals/builtins/process/usleep.md index a5faa774cf..a570a44137 100644 --- a/docs/internals/builtins/process/usleep.md +++ b/docs/internals/builtins/process/usleep.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/usleep.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/usleep.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/regex/mb_ereg_match.md b/docs/internals/builtins/regex/mb_ereg_match.md index be0cd62ec7..9600064748 100644 --- a/docs/internals/builtins/regex/mb_ereg_match.md +++ b/docs/internals/builtins/regex/mb_ereg_match.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/mb_ereg_match.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/mb_ereg_match.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/regex/preg_match.md b/docs/internals/builtins/regex/preg_match.md index b97f076db8..2b36614e83 100644 --- a/docs/internals/builtins/regex/preg_match.md +++ b/docs/internals/builtins/regex/preg_match.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/preg_match.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/preg_match.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/regex/preg_match_all.md b/docs/internals/builtins/regex/preg_match_all.md index a6bd8f5511..dfcdacc234 100644 --- a/docs/internals/builtins/regex/preg_match_all.md +++ b/docs/internals/builtins/regex/preg_match_all.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/preg_match_all.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/preg_match_all.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/regex/preg_replace.md b/docs/internals/builtins/regex/preg_replace.md index d31486a9a7..da28341b2c 100644 --- a/docs/internals/builtins/regex/preg_replace.md +++ b/docs/internals/builtins/regex/preg_replace.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/preg_replace.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/preg_replace.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/regex/preg_replace_callback.md b/docs/internals/builtins/regex/preg_replace_callback.md index aa5e112047..4aa525a0e2 100644 --- a/docs/internals/builtins/regex/preg_replace_callback.md +++ b/docs/internals/builtins/regex/preg_replace_callback.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/callables/preg_replace_callback.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/callables/preg_replace_callback.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/regex/preg_split.md b/docs/internals/builtins/regex/preg_split.md index fad4325edb..d28cbc4423 100644 --- a/docs/internals/builtins/regex/preg_split.md +++ b/docs/internals/builtins/regex/preg_split.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/system/preg_split.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/system/preg_split.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/iterator_apply.md b/docs/internals/builtins/spl/iterator_apply.md index 77d4fa93e5..49543aa486 100644 --- a/docs/internals/builtins/spl/iterator_apply.md +++ b/docs/internals/builtins/spl/iterator_apply.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/iterator_apply.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/iterator_apply.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/iterator_count.md b/docs/internals/builtins/spl/iterator_count.md index d7ba3abc78..6dd7bc64d0 100644 --- a/docs/internals/builtins/spl/iterator_count.md +++ b/docs/internals/builtins/spl/iterator_count.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/iterator_count.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/iterator_count.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/iterator_to_array.md b/docs/internals/builtins/spl/iterator_to_array.md index 390f2da1e0..641cfa817e 100644 --- a/docs/internals/builtins/spl/iterator_to_array.md +++ b/docs/internals/builtins/spl/iterator_to_array.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/iterator_to_array.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/iterator_to_array.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/spl_autoload.md b/docs/internals/builtins/spl/spl_autoload.md index b8a8c91494..c841e025fe 100644 --- a/docs/internals/builtins/spl/spl_autoload.md +++ b/docs/internals/builtins/spl/spl_autoload.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/spl_autoload.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/spl_autoload.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/spl_autoload_call.md b/docs/internals/builtins/spl/spl_autoload_call.md index f49293e358..be7ac6e5e9 100644 --- a/docs/internals/builtins/spl/spl_autoload_call.md +++ b/docs/internals/builtins/spl/spl_autoload_call.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/spl_autoload_call.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/spl_autoload_call.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/spl_autoload_extensions.md b/docs/internals/builtins/spl/spl_autoload_extensions.md index da0c9b6a62..55a8874d1e 100644 --- a/docs/internals/builtins/spl/spl_autoload_extensions.md +++ b/docs/internals/builtins/spl/spl_autoload_extensions.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/spl_autoload_extensions.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/spl_autoload_extensions.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/spl_autoload_functions.md b/docs/internals/builtins/spl/spl_autoload_functions.md index 77a8be86b7..790548c30e 100644 --- a/docs/internals/builtins/spl/spl_autoload_functions.md +++ b/docs/internals/builtins/spl/spl_autoload_functions.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/spl_autoload_functions.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/spl_autoload_functions.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/spl_autoload_register.md b/docs/internals/builtins/spl/spl_autoload_register.md index ad2a257b35..71106a199c 100644 --- a/docs/internals/builtins/spl/spl_autoload_register.md +++ b/docs/internals/builtins/spl/spl_autoload_register.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/spl_autoload_register.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/spl_autoload_register.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/spl_autoload_unregister.md b/docs/internals/builtins/spl/spl_autoload_unregister.md index 47e7c59d2d..33975f78e7 100644 --- a/docs/internals/builtins/spl/spl_autoload_unregister.md +++ b/docs/internals/builtins/spl/spl_autoload_unregister.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/spl_autoload_unregister.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/spl_autoload_unregister.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/spl_classes.md b/docs/internals/builtins/spl/spl_classes.md index ba4f9e1211..1c841cf4e4 100644 --- a/docs/internals/builtins/spl/spl_classes.md +++ b/docs/internals/builtins/spl/spl_classes.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/spl_classes.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/spl_classes.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/spl_object_hash.md b/docs/internals/builtins/spl/spl_object_hash.md index 5d3f7ad1fa..bdc677517c 100644 --- a/docs/internals/builtins/spl/spl_object_hash.md +++ b/docs/internals/builtins/spl/spl_object_hash.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/spl_object_hash.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/spl_object_hash.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/spl/spl_object_id.md b/docs/internals/builtins/spl/spl_object_id.md index d804c507f3..0b9c483e0c 100644 --- a/docs/internals/builtins/spl/spl_object_id.md +++ b/docs/internals/builtins/spl/spl_object_id.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/spl/spl_object_id.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/spl/spl_object_id.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/streams/fsockopen.md b/docs/internals/builtins/streams/fsockopen.md index d05783439c..170826d5d7 100644 --- a/docs/internals/builtins/streams/fsockopen.md +++ b/docs/internals/builtins/streams/fsockopen.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/fsockopen.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/fsockopen.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/streams/pfsockopen.md b/docs/internals/builtins/streams/pfsockopen.md index 6c5454acd5..1aaf4d3efb 100644 --- a/docs/internals/builtins/streams/pfsockopen.md +++ b/docs/internals/builtins/streams/pfsockopen.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/pfsockopen.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/pfsockopen.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/streams/stream_bucket_append.md b/docs/internals/builtins/streams/stream_bucket_append.md index 5c4b926522..b59cbb74a2 100644 --- a/docs/internals/builtins/streams/stream_bucket_append.md +++ b/docs/internals/builtins/streams/stream_bucket_append.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_bucket_append.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_bucket_append.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/streams/stream_bucket_prepend.md b/docs/internals/builtins/streams/stream_bucket_prepend.md index 29b938a661..0c569114ad 100644 --- a/docs/internals/builtins/streams/stream_bucket_prepend.md +++ b/docs/internals/builtins/streams/stream_bucket_prepend.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_bucket_prepend.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_bucket_prepend.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/streams/stream_filter_append.md b/docs/internals/builtins/streams/stream_filter_append.md index ceb7a0255c..01e129dd20 100644 --- a/docs/internals/builtins/streams/stream_filter_append.md +++ b/docs/internals/builtins/streams/stream_filter_append.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_filter_append.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_filter_append.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/streams/stream_filter_prepend.md b/docs/internals/builtins/streams/stream_filter_prepend.md index 49f91d0f21..876d793ed4 100644 --- a/docs/internals/builtins/streams/stream_filter_prepend.md +++ b/docs/internals/builtins/streams/stream_filter_prepend.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/io/stream_filter_prepend.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/io/stream_filter_prepend.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/addslashes.md b/docs/internals/builtins/string/addslashes.md index cc8c1bf8b3..d90b94e5d5 100644 --- a/docs/internals/builtins/string/addslashes.md +++ b/docs/internals/builtins/string/addslashes.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/addslashes.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/addslashes.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/base64_decode.md b/docs/internals/builtins/string/base64_decode.md index 672ade947f..a1ce7ca2be 100644 --- a/docs/internals/builtins/string/base64_decode.md +++ b/docs/internals/builtins/string/base64_decode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/base64_decode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/base64_decode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/base64_encode.md b/docs/internals/builtins/string/base64_encode.md index 7a9ae9a589..21c33538d0 100644 --- a/docs/internals/builtins/string/base64_encode.md +++ b/docs/internals/builtins/string/base64_encode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/base64_encode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/base64_encode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/bin2hex.md b/docs/internals/builtins/string/bin2hex.md index fa0bf1fd30..64156fb181 100644 --- a/docs/internals/builtins/string/bin2hex.md +++ b/docs/internals/builtins/string/bin2hex.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/bin2hex.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/bin2hex.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/chop.md b/docs/internals/builtins/string/chop.md index 538fa89c3d..27ad9c57ff 100644 --- a/docs/internals/builtins/string/chop.md +++ b/docs/internals/builtins/string/chop.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/chop.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/chop.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/chr.md b/docs/internals/builtins/string/chr.md index 06398e07d9..bd6b407fbc 100644 --- a/docs/internals/builtins/string/chr.md +++ b/docs/internals/builtins/string/chr.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/chr.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/chr.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/crc32.md b/docs/internals/builtins/string/crc32.md index dd94e60d25..307acc868c 100644 --- a/docs/internals/builtins/string/crc32.md +++ b/docs/internals/builtins/string/crc32.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/crc32.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/crc32.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/explode.md b/docs/internals/builtins/string/explode.md index 159a5e3e31..04f12fa36a 100644 --- a/docs/internals/builtins/string/explode.md +++ b/docs/internals/builtins/string/explode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/explode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/explode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/grapheme_strrev.md b/docs/internals/builtins/string/grapheme_strrev.md index 96dcc90a52..45eed21056 100644 --- a/docs/internals/builtins/string/grapheme_strrev.md +++ b/docs/internals/builtins/string/grapheme_strrev.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/grapheme_strrev.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/grapheme_strrev.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/gzcompress.md b/docs/internals/builtins/string/gzcompress.md index 407bddd18e..14f186e03c 100644 --- a/docs/internals/builtins/string/gzcompress.md +++ b/docs/internals/builtins/string/gzcompress.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/gzcompress.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/gzcompress.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/gzdeflate.md b/docs/internals/builtins/string/gzdeflate.md index 887a5e1662..7f01fe46df 100644 --- a/docs/internals/builtins/string/gzdeflate.md +++ b/docs/internals/builtins/string/gzdeflate.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/gzdeflate.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/gzdeflate.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/gzinflate.md b/docs/internals/builtins/string/gzinflate.md index c746845a98..c891851bc9 100644 --- a/docs/internals/builtins/string/gzinflate.md +++ b/docs/internals/builtins/string/gzinflate.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/gzinflate.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/gzinflate.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/gzuncompress.md b/docs/internals/builtins/string/gzuncompress.md index 6a1af8efcc..7e1b5a694e 100644 --- a/docs/internals/builtins/string/gzuncompress.md +++ b/docs/internals/builtins/string/gzuncompress.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/gzuncompress.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/gzuncompress.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/hash.md b/docs/internals/builtins/string/hash.md index cfbd4489d4..c81df98044 100644 --- a/docs/internals/builtins/string/hash.md +++ b/docs/internals/builtins/string/hash.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/hash.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/hash.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/hash_algos.md b/docs/internals/builtins/string/hash_algos.md index 5250402718..e6dae1428b 100644 --- a/docs/internals/builtins/string/hash_algos.md +++ b/docs/internals/builtins/string/hash_algos.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/hash_algos.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/hash_algos.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/hash_equals.md b/docs/internals/builtins/string/hash_equals.md index a5967aa604..57db497f38 100644 --- a/docs/internals/builtins/string/hash_equals.md +++ b/docs/internals/builtins/string/hash_equals.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/hash_equals.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/hash_equals.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/hash_hmac.md b/docs/internals/builtins/string/hash_hmac.md index 5ee2e570a7..659a56b165 100644 --- a/docs/internals/builtins/string/hash_hmac.md +++ b/docs/internals/builtins/string/hash_hmac.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/hash_hmac.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/hash_hmac.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/hex2bin.md b/docs/internals/builtins/string/hex2bin.md index 31f2d70660..c38d52a5a2 100644 --- a/docs/internals/builtins/string/hex2bin.md +++ b/docs/internals/builtins/string/hex2bin.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/hex2bin.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/hex2bin.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/html_entity_decode.md b/docs/internals/builtins/string/html_entity_decode.md index e9ca3b33ed..80b768d932 100644 --- a/docs/internals/builtins/string/html_entity_decode.md +++ b/docs/internals/builtins/string/html_entity_decode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/html_entity_decode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/html_entity_decode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/htmlentities.md b/docs/internals/builtins/string/htmlentities.md index f0bdd1b0e0..5b872c2490 100644 --- a/docs/internals/builtins/string/htmlentities.md +++ b/docs/internals/builtins/string/htmlentities.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/htmlentities.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/htmlentities.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/htmlspecialchars.md b/docs/internals/builtins/string/htmlspecialchars.md index 98843a05e2..0ce4e8b791 100644 --- a/docs/internals/builtins/string/htmlspecialchars.md +++ b/docs/internals/builtins/string/htmlspecialchars.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/htmlspecialchars.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/htmlspecialchars.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/implode.md b/docs/internals/builtins/string/implode.md index 8484efbf59..31ff513674 100644 --- a/docs/internals/builtins/string/implode.md +++ b/docs/internals/builtins/string/implode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/implode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/implode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/inet_ntop.md b/docs/internals/builtins/string/inet_ntop.md index e55166d71d..8c21a2e60b 100644 --- a/docs/internals/builtins/string/inet_ntop.md +++ b/docs/internals/builtins/string/inet_ntop.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/inet_ntop.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/inet_ntop.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/inet_pton.md b/docs/internals/builtins/string/inet_pton.md index 78e1245b7c..4d17d5c46a 100644 --- a/docs/internals/builtins/string/inet_pton.md +++ b/docs/internals/builtins/string/inet_pton.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/inet_pton.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/inet_pton.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/ip2long.md b/docs/internals/builtins/string/ip2long.md index a8ef2bcaf6..c95058c6bc 100644 --- a/docs/internals/builtins/string/ip2long.md +++ b/docs/internals/builtins/string/ip2long.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/ip2long.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/ip2long.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/lcfirst.md b/docs/internals/builtins/string/lcfirst.md index 6075b98476..054730d81a 100644 --- a/docs/internals/builtins/string/lcfirst.md +++ b/docs/internals/builtins/string/lcfirst.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/lcfirst.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/lcfirst.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/long2ip.md b/docs/internals/builtins/string/long2ip.md index bacac1089d..da3f0528c6 100644 --- a/docs/internals/builtins/string/long2ip.md +++ b/docs/internals/builtins/string/long2ip.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/long2ip.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/long2ip.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/ltrim.md b/docs/internals/builtins/string/ltrim.md index b456cc6810..88c0aad21b 100644 --- a/docs/internals/builtins/string/ltrim.md +++ b/docs/internals/builtins/string/ltrim.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/ltrim.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/ltrim.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/mb_strlen.md b/docs/internals/builtins/string/mb_strlen.md index b2467dffad..004b19e2d5 100644 --- a/docs/internals/builtins/string/mb_strlen.md +++ b/docs/internals/builtins/string/mb_strlen.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/mb_strlen.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/mb_strlen.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/md5.md b/docs/internals/builtins/string/md5.md index cec099b9ae..b8cd9f8053 100644 --- a/docs/internals/builtins/string/md5.md +++ b/docs/internals/builtins/string/md5.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/md5.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/md5.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/nl2br.md b/docs/internals/builtins/string/nl2br.md index 44372bbcd7..1365d25c16 100644 --- a/docs/internals/builtins/string/nl2br.md +++ b/docs/internals/builtins/string/nl2br.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/nl2br.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/nl2br.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/number_format.md b/docs/internals/builtins/string/number_format.md index d14e00ba28..09b629761c 100644 --- a/docs/internals/builtins/string/number_format.md +++ b/docs/internals/builtins/string/number_format.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/number_format.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/number_format.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/ord.md b/docs/internals/builtins/string/ord.md index f53fa3099d..efef82e763 100644 --- a/docs/internals/builtins/string/ord.md +++ b/docs/internals/builtins/string/ord.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/ord.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/ord.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/parse_url.md b/docs/internals/builtins/string/parse_url.md index cc843abd95..a12efedfda 100644 --- a/docs/internals/builtins/string/parse_url.md +++ b/docs/internals/builtins/string/parse_url.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/parse_url.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/parse_url.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/printf.md b/docs/internals/builtins/string/printf.md index c89a0a0ad8..5a277e58c4 100644 --- a/docs/internals/builtins/string/printf.md +++ b/docs/internals/builtins/string/printf.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/printf.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/printf.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/rawurldecode.md b/docs/internals/builtins/string/rawurldecode.md index c2bb7a2083..43686c52ca 100644 --- a/docs/internals/builtins/string/rawurldecode.md +++ b/docs/internals/builtins/string/rawurldecode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/rawurldecode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/rawurldecode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/rawurlencode.md b/docs/internals/builtins/string/rawurlencode.md index c8616847e3..8a3ab9f789 100644 --- a/docs/internals/builtins/string/rawurlencode.md +++ b/docs/internals/builtins/string/rawurlencode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/rawurlencode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/rawurlencode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/rtrim.md b/docs/internals/builtins/string/rtrim.md index f114f468d7..e23ea8c117 100644 --- a/docs/internals/builtins/string/rtrim.md +++ b/docs/internals/builtins/string/rtrim.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/rtrim.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/rtrim.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/sha1.md b/docs/internals/builtins/string/sha1.md index ec3b42e5c6..ebb72dd59c 100644 --- a/docs/internals/builtins/string/sha1.md +++ b/docs/internals/builtins/string/sha1.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/sha1.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/sha1.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/sprintf.md b/docs/internals/builtins/string/sprintf.md index b505a965e2..e0beb36005 100644 --- a/docs/internals/builtins/string/sprintf.md +++ b/docs/internals/builtins/string/sprintf.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/sprintf.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/sprintf.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/sscanf.md b/docs/internals/builtins/string/sscanf.md index ea4094d5ca..0736ceda74 100644 --- a/docs/internals/builtins/string/sscanf.md +++ b/docs/internals/builtins/string/sscanf.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/sscanf.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/sscanf.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/str_contains.md b/docs/internals/builtins/string/str_contains.md index 1a22fbbf4a..7aee5253da 100644 --- a/docs/internals/builtins/string/str_contains.md +++ b/docs/internals/builtins/string/str_contains.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/str_contains.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/str_contains.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/str_ends_with.md b/docs/internals/builtins/string/str_ends_with.md index 03c9878027..7f0965744d 100644 --- a/docs/internals/builtins/string/str_ends_with.md +++ b/docs/internals/builtins/string/str_ends_with.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/str_ends_with.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/str_ends_with.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/str_ireplace.md b/docs/internals/builtins/string/str_ireplace.md index 90d2e2b54f..e2f15c24a5 100644 --- a/docs/internals/builtins/string/str_ireplace.md +++ b/docs/internals/builtins/string/str_ireplace.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/str_ireplace.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/str_ireplace.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/str_pad.md b/docs/internals/builtins/string/str_pad.md index 0ca7a6d70d..56754e7280 100644 --- a/docs/internals/builtins/string/str_pad.md +++ b/docs/internals/builtins/string/str_pad.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/str_pad.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/str_pad.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/str_repeat.md b/docs/internals/builtins/string/str_repeat.md index b9822ed0ab..3091ee926c 100644 --- a/docs/internals/builtins/string/str_repeat.md +++ b/docs/internals/builtins/string/str_repeat.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/str_repeat.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/str_repeat.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/str_replace.md b/docs/internals/builtins/string/str_replace.md index c8d92331b3..0bd91bbb78 100644 --- a/docs/internals/builtins/string/str_replace.md +++ b/docs/internals/builtins/string/str_replace.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/str_replace.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/str_replace.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/str_split.md b/docs/internals/builtins/string/str_split.md index 7d12b01260..489f88a9ab 100644 --- a/docs/internals/builtins/string/str_split.md +++ b/docs/internals/builtins/string/str_split.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/str_split.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/str_split.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/str_starts_with.md b/docs/internals/builtins/string/str_starts_with.md index 3f82185588..1f80b3c1de 100644 --- a/docs/internals/builtins/string/str_starts_with.md +++ b/docs/internals/builtins/string/str_starts_with.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/str_starts_with.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/str_starts_with.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/strcasecmp.md b/docs/internals/builtins/string/strcasecmp.md index 30ae91a4b1..8ac83f8849 100644 --- a/docs/internals/builtins/string/strcasecmp.md +++ b/docs/internals/builtins/string/strcasecmp.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/strcasecmp.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/strcasecmp.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/strcmp.md b/docs/internals/builtins/string/strcmp.md index 2d3e7d9b62..e6b01a9b04 100644 --- a/docs/internals/builtins/string/strcmp.md +++ b/docs/internals/builtins/string/strcmp.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/strcmp.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/strcmp.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/stripslashes.md b/docs/internals/builtins/string/stripslashes.md index 256e0ca533..ad16e5b75b 100644 --- a/docs/internals/builtins/string/stripslashes.md +++ b/docs/internals/builtins/string/stripslashes.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/stripslashes.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/stripslashes.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/strlen.md b/docs/internals/builtins/string/strlen.md index 58ff404239..26c8918073 100644 --- a/docs/internals/builtins/string/strlen.md +++ b/docs/internals/builtins/string/strlen.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/strlen.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/strlen.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/strpos.md b/docs/internals/builtins/string/strpos.md index dd96792862..24b10405a8 100644 --- a/docs/internals/builtins/string/strpos.md +++ b/docs/internals/builtins/string/strpos.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/strpos.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/strpos.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/strrev.md b/docs/internals/builtins/string/strrev.md index c84be963bb..bb466da91b 100644 --- a/docs/internals/builtins/string/strrev.md +++ b/docs/internals/builtins/string/strrev.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/strrev.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/strrev.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/strrpos.md b/docs/internals/builtins/string/strrpos.md index 6657679485..8fb4e91d7d 100644 --- a/docs/internals/builtins/string/strrpos.md +++ b/docs/internals/builtins/string/strrpos.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/strrpos.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/strrpos.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/strstr.md b/docs/internals/builtins/string/strstr.md index 74219ec914..1cd19b3f50 100644 --- a/docs/internals/builtins/string/strstr.md +++ b/docs/internals/builtins/string/strstr.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/strstr.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/strstr.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/strtolower.md b/docs/internals/builtins/string/strtolower.md index fc25a37b74..ac344c5b59 100644 --- a/docs/internals/builtins/string/strtolower.md +++ b/docs/internals/builtins/string/strtolower.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/strtolower.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/strtolower.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/strtoupper.md b/docs/internals/builtins/string/strtoupper.md index 8b4dcf7e58..d036835132 100644 --- a/docs/internals/builtins/string/strtoupper.md +++ b/docs/internals/builtins/string/strtoupper.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/strtoupper.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/strtoupper.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/substr.md b/docs/internals/builtins/string/substr.md index 4f4e9bf317..ab7c416467 100644 --- a/docs/internals/builtins/string/substr.md +++ b/docs/internals/builtins/string/substr.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/substr.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/substr.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/substr_replace.md b/docs/internals/builtins/string/substr_replace.md index cd7e5d6471..37242f6f38 100644 --- a/docs/internals/builtins/string/substr_replace.md +++ b/docs/internals/builtins/string/substr_replace.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/substr_replace.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/substr_replace.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/trim.md b/docs/internals/builtins/string/trim.md index 842f368785..da4e3ccbde 100644 --- a/docs/internals/builtins/string/trim.md +++ b/docs/internals/builtins/string/trim.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/trim.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/trim.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/ucfirst.md b/docs/internals/builtins/string/ucfirst.md index cc42bf5d17..e3a2b84802 100644 --- a/docs/internals/builtins/string/ucfirst.md +++ b/docs/internals/builtins/string/ucfirst.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/ucfirst.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/ucfirst.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/ucwords.md b/docs/internals/builtins/string/ucwords.md index ec27eff1d0..f3df88d485 100644 --- a/docs/internals/builtins/string/ucwords.md +++ b/docs/internals/builtins/string/ucwords.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/ucwords.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/ucwords.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/urldecode.md b/docs/internals/builtins/string/urldecode.md index 65b8474d43..8427fb0ebf 100644 --- a/docs/internals/builtins/string/urldecode.md +++ b/docs/internals/builtins/string/urldecode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/urldecode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/urldecode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/urlencode.md b/docs/internals/builtins/string/urlencode.md index 712c34b7d8..607d26b61c 100644 --- a/docs/internals/builtins/string/urlencode.md +++ b/docs/internals/builtins/string/urlencode.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/urlencode.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/urlencode.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/vprintf.md b/docs/internals/builtins/string/vprintf.md index 2a288395a1..b118a1a563 100644 --- a/docs/internals/builtins/string/vprintf.md +++ b/docs/internals/builtins/string/vprintf.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/vprintf.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/vprintf.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/vsprintf.md b/docs/internals/builtins/string/vsprintf.md index e550f063b4..3727b900ee 100644 --- a/docs/internals/builtins/string/vsprintf.md +++ b/docs/internals/builtins/string/vsprintf.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/vsprintf.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/vsprintf.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/string/wordwrap.md b/docs/internals/builtins/string/wordwrap.md index 249f3c3d4c..84bb6244a3 100644 --- a/docs/internals/builtins/string/wordwrap.md +++ b/docs/internals/builtins/string/wordwrap.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/wordwrap.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/wordwrap.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/boolval.md b/docs/internals/builtins/type/boolval.md index 36db471cb6..283f5f963c 100644 --- a/docs/internals/builtins/type/boolval.md +++ b/docs/internals/builtins/type/boolval.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/boolval.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/boolval.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/ctype_alnum.md b/docs/internals/builtins/type/ctype_alnum.md index e3745ff3d2..03dc8288d6 100644 --- a/docs/internals/builtins/type/ctype_alnum.md +++ b/docs/internals/builtins/type/ctype_alnum.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/ctype_alnum.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/ctype_alnum.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/ctype_alpha.md b/docs/internals/builtins/type/ctype_alpha.md index 4e10119c56..6e48c2e492 100644 --- a/docs/internals/builtins/type/ctype_alpha.md +++ b/docs/internals/builtins/type/ctype_alpha.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/ctype_alpha.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/ctype_alpha.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/ctype_digit.md b/docs/internals/builtins/type/ctype_digit.md index f0cc9bc267..6d4b0959af 100644 --- a/docs/internals/builtins/type/ctype_digit.md +++ b/docs/internals/builtins/type/ctype_digit.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/ctype_digit.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/ctype_digit.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/ctype_space.md b/docs/internals/builtins/type/ctype_space.md index c7fb1a1542..030464d45d 100644 --- a/docs/internals/builtins/type/ctype_space.md +++ b/docs/internals/builtins/type/ctype_space.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/string/ctype_space.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/string/ctype_space.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/floatval.md b/docs/internals/builtins/type/floatval.md index c900a5d084..9906afcbe7 100644 --- a/docs/internals/builtins/type/floatval.md +++ b/docs/internals/builtins/type/floatval.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/floatval.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/floatval.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/get_resource_id.md b/docs/internals/builtins/type/get_resource_id.md index d3a14cc10d..90f0a5d915 100644 --- a/docs/internals/builtins/type/get_resource_id.md +++ b/docs/internals/builtins/type/get_resource_id.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/get_resource_id.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/get_resource_id.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/get_resource_type.md b/docs/internals/builtins/type/get_resource_type.md index e21cffeff0..891ae663a1 100644 --- a/docs/internals/builtins/type/get_resource_type.md +++ b/docs/internals/builtins/type/get_resource_type.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/get_resource_type.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/get_resource_type.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/gettype.md b/docs/internals/builtins/type/gettype.md index 48adde8b65..e70eb8cc0f 100644 --- a/docs/internals/builtins/type/gettype.md +++ b/docs/internals/builtins/type/gettype.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/gettype.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/gettype.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/intval.md b/docs/internals/builtins/type/intval.md index 6d2381a141..d86f03e2b9 100644 --- a/docs/internals/builtins/type/intval.md +++ b/docs/internals/builtins/type/intval.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/intval.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/intval.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_array.md b/docs/internals/builtins/type/is_array.md index 14076ed0f1..af3c32ab8d 100644 --- a/docs/internals/builtins/type/is_array.md +++ b/docs/internals/builtins/type/is_array.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_array.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_array.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_bool.md b/docs/internals/builtins/type/is_bool.md index 8b16cd234a..db6bd7ba3c 100644 --- a/docs/internals/builtins/type/is_bool.md +++ b/docs/internals/builtins/type/is_bool.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_bool.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_bool.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_callable.md b/docs/internals/builtins/type/is_callable.md index 0141aadb7f..882b9b3f43 100644 --- a/docs/internals/builtins/type/is_callable.md +++ b/docs/internals/builtins/type/is_callable.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_callable.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_callable.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_double.md b/docs/internals/builtins/type/is_double.md index e8fa41d787..fb9d709f16 100644 --- a/docs/internals/builtins/type/is_double.md +++ b/docs/internals/builtins/type/is_double.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_double.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_double.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_float.md b/docs/internals/builtins/type/is_float.md index 35914cd419..5a0c06b689 100644 --- a/docs/internals/builtins/type/is_float.md +++ b/docs/internals/builtins/type/is_float.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_float.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_float.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_int.md b/docs/internals/builtins/type/is_int.md index d4b40bc4ed..59aaf52bac 100644 --- a/docs/internals/builtins/type/is_int.md +++ b/docs/internals/builtins/type/is_int.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_int.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_int.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_integer.md b/docs/internals/builtins/type/is_integer.md index 6e7b1fc92b..ba54247f98 100644 --- a/docs/internals/builtins/type/is_integer.md +++ b/docs/internals/builtins/type/is_integer.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_integer.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_integer.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_iterable.md b/docs/internals/builtins/type/is_iterable.md index a641007295..f3ff6bcc89 100644 --- a/docs/internals/builtins/type/is_iterable.md +++ b/docs/internals/builtins/type/is_iterable.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_iterable.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_iterable.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_long.md b/docs/internals/builtins/type/is_long.md index 135aa7773d..aa8110055a 100644 --- a/docs/internals/builtins/type/is_long.md +++ b/docs/internals/builtins/type/is_long.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_long.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_long.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_null.md b/docs/internals/builtins/type/is_null.md index 5665509ed9..675fd7945c 100644 --- a/docs/internals/builtins/type/is_null.md +++ b/docs/internals/builtins/type/is_null.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_null.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_null.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_numeric.md b/docs/internals/builtins/type/is_numeric.md index abd4acd88b..68d7909be7 100644 --- a/docs/internals/builtins/type/is_numeric.md +++ b/docs/internals/builtins/type/is_numeric.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_numeric.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_numeric.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_object.md b/docs/internals/builtins/type/is_object.md index b5dea51e04..72ed3e42b2 100644 --- a/docs/internals/builtins/type/is_object.md +++ b/docs/internals/builtins/type/is_object.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_object.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_object.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_real.md b/docs/internals/builtins/type/is_real.md index 1a945560b8..fb35a937e9 100644 --- a/docs/internals/builtins/type/is_real.md +++ b/docs/internals/builtins/type/is_real.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_real.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_real.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_resource.md b/docs/internals/builtins/type/is_resource.md index 4f2d88752b..88b24c22db 100644 --- a/docs/internals/builtins/type/is_resource.md +++ b/docs/internals/builtins/type/is_resource.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_resource.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_resource.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_scalar.md b/docs/internals/builtins/type/is_scalar.md index 2b3b1d356b..3992075147 100644 --- a/docs/internals/builtins/type/is_scalar.md +++ b/docs/internals/builtins/type/is_scalar.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_scalar.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_scalar.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/is_string.md b/docs/internals/builtins/type/is_string.md index e1b427ddd7..6a044725b0 100644 --- a/docs/internals/builtins/type/is_string.md +++ b/docs/internals/builtins/type/is_string.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/is_string.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/is_string.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/settype.md b/docs/internals/builtins/type/settype.md index 2bce1c40c1..bfde8d1b39 100644 --- a/docs/internals/builtins/type/settype.md +++ b/docs/internals/builtins/type/settype.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/settype.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/settype.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/builtins/type/strval.md b/docs/internals/builtins/type/strval.md index 301e39d641..a57fc34d89 100644 --- a/docs/internals/builtins/type/strval.md +++ b/docs/internals/builtins/type/strval.md @@ -10,7 +10,7 @@ sidebar: ## Where it lives - **Signature**: [`src/builtins/types/strval.rs`](https://github.com/illegalstudio/elephc/blob/main/src/builtins/types/strval.rs) -- **Lowering**: [`src/builtins/semantics.rs`:423](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L423) (`lower_registry_call`) +- **Lowering**: [`src/builtins/semantics.rs`:448](https://github.com/illegalstudio/elephc/blob/main/src/builtins/semantics.rs#L448) (`lower_registry_call`) - **Function symbol**: `lower_registry_call()` diff --git a/docs/internals/eval-runtime.md b/docs/internals/eval-runtime.md index 62dea5fc0e..30954b8178 100644 --- a/docs/internals/eval-runtime.md +++ b/docs/internals/eval-runtime.md @@ -172,6 +172,14 @@ fatals, thrown values, early fragment returns, and function cleanup must all balance those cells. Persistent declarations and metadata live in the eval context until its owning generated function or process scope is destroyed. +Eval array reads use a dedicated owned shared-cell mode. Unlike an ordinary +PHP array read, which detaches a boxed zval to preserve value semantics, the +bridge must retain the exact stored cell because that handle can be the +writeback target for an AOT by-reference method, constructor, reflection, or +callable invocation. This mode is separate from the nested-write fetch, which +COW-normalizes the outer container and detaches the selected zval before +mutation. + ## Parsing and cache Dynamic source is parsed into Magician's immutable EvalIR. The process-wide diff --git a/docs/internals/the-ir.md b/docs/internals/the-ir.md index 5c91bd9224..222f5b0b57 100644 --- a/docs/internals/the-ir.md +++ b/docs/internals/the-ir.md @@ -512,6 +512,7 @@ preserves PHP exponentiation result rules. | `ResourceToStr` | `I64` resource | `Str` | may allocate, may warn | | `Cast(to_php_type)` | typed value | matching IR type | PHP cast effects | | `MixedBox` | non-mixed value | `Heap(Mixed)` | `alloc_heap`, maybe `refcount_op` | +| `MixedClone` | `Heap(Mixed)` | owned `Heap(Mixed)` value read | `reads_heap`, `alloc_heap`, `refcount_op` | | `MixedUnbox(expected)` | `Heap(Mixed)` | expected storage | `reads_heap`, `may_fatal` | | `MixedTagOf` | `Heap(Mixed)` | `I64` | `reads_heap` | | `ArrayToMixed`, `HashToMixed` | array/hash | `Heap(Mixed)` | `alloc_heap`, `refcount_op` | @@ -541,9 +542,9 @@ across a reset point. | `HashNew(key_type, value_type, capacity)` | none | `Heap(Hash)` | `alloc_heap` | | `ArrayLen`, `HashLen` | container | `I64` | `reads_heap` | | `ArrayGet` | array, index | element type | `reads_heap`, `may_warn`, maybe `may_fatal` | -| `ArrayGetForWrite` | array, index (`I64`) | element type, **borrowed** | `reads_heap`, `writes_heap`, `writes_local`, `alloc_heap`, `refcount_op`, `may_warn` | | `HashGet` | hash, key | value type | `reads_heap`, `may_warn`, maybe `may_fatal` | -| `HashGetForWrite` | hash, key | value type, **borrowed** | `reads_heap`, `writes_heap`, `writes_local`, `alloc_heap`, `refcount_op`, `may_warn` | +| `ArrayGetForWrite` | array, index (`I64`) | retained boxed `Mixed` cell or typed **borrowed** element | `reads_heap`, `writes_heap`, `writes_local`, `alloc_heap`, `refcount_op`, `may_warn`, maybe `may_fatal` | +| `HashGetForWrite` | hash, key | retained boxed `Mixed` cell or typed **borrowed** value | `reads_heap`, `writes_heap`, `writes_local`, `alloc_heap`, `refcount_op`, `may_warn`, maybe `may_fatal` | | `ArraySet` | array, index, value | `Void` | `writes_heap`, maybe `alloc_heap`, `refcount_op` | | `HashSet` | hash, key, value | `Void` | `writes_heap`, maybe `alloc_heap`, `refcount_op` | | `ArrayPush`, `HashAppend` | container, value | `Void` | `writes_heap`, maybe `alloc_heap`, `refcount_op` | @@ -555,16 +556,29 @@ across a reset point. | `OffsetUnset` | container, key | `Void` | `writes_heap`, `refcount_op` | | `ListUnpack` | array value, slot list | `Void` | `reads_heap`, `writes_local` | +Ordinary `ArrayGet`/`HashGet` reads of boxed `Mixed` values materialize an independent zval cell, +preserving PHP value semantics. Resource payloads are the intentional exception: the read retains +the existing resource cell so aliases share the cursor and close/destructor lifetime, as PHP +resources do. Nested assignments use the explicit `*GetForWrite` operations with a `Mixed` result +for their parent read instead: the root container is COW-normalized and stored back first, then the +selected stored cell is returned with a retained reference. Typed entries are promoted to boxed +storage in place. The following `RuntimeCall` writer can therefore publish copy-on-write +replacements back into the owning slot without making ordinary reads alias. +The dynamic `MixedArrayGetForWrite` equivalent additionally COW-normalizes a runtime indexed array +or associative hash, republishes a possibly split container in its cloned owning Mixed cell, and +detaches the selected zval; the ordinary dynamic read clones that cell without mutating storage. + All mutating operations must preserve copy-on-write. The builder emits `ArrayEnsureUnique`/`HashEnsureUnique` before mutation unless prior ownership proofs make it unnecessary. -`ArrayGetForWrite` and `HashGetForWrite` are the read side of that rule for a -container element that is about to be mutated through an alias — today, the -source of a by-reference `foreach` (issue #580). Unlike `ArrayGet`/`HashGet` they -take no reference for the caller; they separate the receiver, then split the -element from any co-owner and store the separated container back into the -receiver's element slot, so the result is owned by the parent and unique. That is +With a typed result, `ArrayGetForWrite` and `HashGetForWrite` are also the read +side of that rule for a container element that is about to be mutated through an +alias — today, the source of a by-reference `foreach` (issue #580). Unlike the +`Mixed` form, the typed form takes no reference for the caller; it separates the +receiver, then splits the element from any co-owner and stores the separated +container back into the receiver's element slot, so the result is owned by the +parent and unique. That is what lets `foreach ($a[0] as &$v)` and `foreach ($h['a'] as &$v)` write through to their sources: the plain retaining read left the element shared, and `IterStart`'s own copy-on-write split then gave the loop a private copy to mutate diff --git a/docs/php/builtins.md b/docs/php/builtins.md index c944be5d89..23db56a131 100644 --- a/docs/php/builtins.md +++ b/docs/php/builtins.md @@ -190,7 +190,7 @@ sidebar: | [`fsync()`](./builtins/io/fsync.md) | `(resource $stream): bool` | `bool` | ✓ | ✓ | | [`ftell()`](./builtins/io/ftell.md) | `(resource $stream): int` | `int` | ✓ | ✓ | | [`ftruncate()`](./builtins/io/ftruncate.md) | `(resource $stream, int $size): bool` | `bool` | ✓ | ✓ | -| [`fwrite()`](./builtins/io/fwrite.md) | `(resource $stream, string $data): int` | `int` | ✓ | ✓ | +| [`fwrite()`](./builtins/io/fwrite.md) | `(resource $stream, string $data): mixed` | `mixed` | ✓ | ✓ | | [`gethostbyaddr()`](./builtins/io/gethostbyaddr.md) | `(string $ip): mixed` | `mixed` | ✓ | ✓ | | [`gethostbyname()`](./builtins/io/gethostbyname.md) | `(string $hostname): string` | `string` | ✓ | ✓ | | [`gethostname()`](./builtins/io/gethostname.md) | `(): string` | `string` | ✓ | ✓ | diff --git a/docs/php/builtins/io.md b/docs/php/builtins/io.md index 473850ffb6..fa81553e34 100644 --- a/docs/php/builtins/io.md +++ b/docs/php/builtins/io.md @@ -32,7 +32,7 @@ sidebar: | [`fsync()`](./io/fsync.md) | `(resource $stream): bool` | `bool` | ✓ | ✓ | | [`ftell()`](./io/ftell.md) | `(resource $stream): int` | `int` | ✓ | ✓ | | [`ftruncate()`](./io/ftruncate.md) | `(resource $stream, int $size): bool` | `bool` | ✓ | ✓ | -| [`fwrite()`](./io/fwrite.md) | `(resource $stream, string $data): int` | `int` | ✓ | ✓ | +| [`fwrite()`](./io/fwrite.md) | `(resource $stream, string $data): mixed` | `mixed` | ✓ | ✓ | | [`gethostbyaddr()`](./io/gethostbyaddr.md) | `(string $ip): mixed` | `mixed` | ✓ | ✓ | | [`gethostbyname()`](./io/gethostbyname.md) | `(string $hostname): string` | `string` | ✓ | ✓ | | [`gethostname()`](./io/gethostname.md) | `(): string` | `string` | ✓ | ✓ | diff --git a/docs/php/builtins/io/fwrite.md b/docs/php/builtins/io/fwrite.md index 97be2516c7..c0d7075e2a 100644 --- a/docs/php/builtins/io/fwrite.md +++ b/docs/php/builtins/io/fwrite.md @@ -8,7 +8,7 @@ sidebar: ## fwrite() ```php -function fwrite(resource $stream, string $data): int +function fwrite(resource $stream, string $data): mixed ``` Binary-safe file write. @@ -17,7 +17,7 @@ Binary-safe file write. - `$stream` (`resource`) - `$data` (`string`) -**Returns**: `int` +**Returns**: `mixed` ## Availability diff --git a/docs/php/pdo.md b/docs/php/pdo.md index 8c06df2d66..a055918634 100644 --- a/docs/php/pdo.md +++ b/docs/php/pdo.md @@ -1,20 +1,60 @@ --- title: "PDO (Databases)" -description: "PDO database access with the SQLite, PostgreSQL, and MySQL/MariaDB drivers: connections, prepared statements, fetch modes, and transactions." +description: "PDO database access with SQLite, PostgreSQL, MySQL/MariaDB, optional PDO_DBLIB, PDO_FIREBIRD, PDO_ODBC, PDO_INFORMIX, PDO_IBM, PDO_SQLSRV, PDO_OCI, and PDO_CUBRID: connections, prepared statements, fetch modes, transactions, and php-src divergences." sidebar: order: 18 --- -elephc supports a practical subset of PHP's PDO database layer, with the -**SQLite**, **PostgreSQL**, and **MySQL / MariaDB** drivers. `PDO`, -`PDOStatement`, and `PDOException` behave like their PHP counterparts for everyday -use: connect, execute, prepare/bind, fetch, and run transactions. The DSN prefix -selects the driver, so the same code works against any of the databases. +elephc implements PDO for the PHP 8.0 through 8.6 compatibility targets, with the +**SQLite**, **PostgreSQL**, **MySQL / MariaDB**, optional **FreeTDS +PDO_DBLIB**, optional **PDO_FIREBIRD**, optional **PDO_ODBC**, and optional +**PDO_INFORMIX**, optional **PDO_IBM**, optional **Microsoft PDO_SQLSRV**, optional +**Oracle PDO_OCI**, and optional **PDO_CUBRID** drivers. `PDO`, `PDOStatement`, and `PDOException` behave like their +PHP counterparts for everyday use: connect, execute, prepare/bind, fetch, and run +transactions. The DSN prefix selects the driver. -Every driver is linked statically (SQLite is bundled; PostgreSQL and MySQL use -pure-Rust clients), so a compiled PDO binary has **no system database-client -dependency** — it runs anywhere the elephc binary runs. SQLite runs in-process; -PostgreSQL and MySQL connect to a running server over the network. +The default drivers are linked statically (SQLite is bundled; PostgreSQL and MySQL +use pure-Rust clients), so their compiled PDO binaries have **no system +database-client dependency**. The optional DBLIB profile deliberately follows PHP +and links the target platform's FreeTDS `libsybdb`; the resulting binary therefore +needs a compatible system client at build and runtime. PDO_ODBC likewise links +unixODBC and delegates database protocols to installed ODBC drivers. The Firebird +profile uses the pure-Rust wire protocol and adds no client-library runtime dependency. +PDO_OCI loads Oracle Instant Client dynamically through ODPI-C, preserving the official +extension's external Oracle-client boundary without requiring proprietary headers at build time. +PDO_INFORMIX follows PECL 1.3.7 and delegates to the IBM/HCL Client SDK through +the platform ODBC driver manager. +PDO_IBM follows PECL 1.7.0 and uses the same CLI/ODBC ABI with an installed IBM +Db2 or Informix client driver. +PDO_SQLSRV follows Microsoft Drivers for PHP for SQL Server 5.13.1 and uses an +installed Microsoft ODBC Driver 18 or 17. +PDO_CUBRID follows the current official external extension and dynamically loads the +same CUBRID CCI client, avoiding a CUBRID SDK requirement while compiling elephc. + +The surface is deliberately honest: where a feature is not implemented, it fails +loudly (a `PDOException`, a `ValueError`, a `TypeError`) rather than silently +returning wrong data. The [Divergences from php-src](#divergences-from-php-src) and +[Limitations](#limitations) sections below enumerate what is different and why — +read them before porting security-sensitive or data-loading code. + +## PHP compatibility version + +PDO's generated surface is selected with `--php-version=8.0` through +`--php-version=8.6`; `ELEPHC_PHP_VERSION` provides the same selection for automation. +The command-line option wins over the environment and the default is PHP 8.5. +Patch versions and values outside this range are rejected. + +| Target | PDO differences selected by elephc | +| --- | --- | +| 8.0 | Core classes and legacy SQLite/PostgreSQL driver methods; no public `queryString`, namespaced driver classes, or `PDO::connect()`. | +| 8.1 | Public `PDOStatement::$queryString` and `PDORow::$queryString`. | +| 8.2–8.3 | Password parameters carry `#[SensitiveParameter]`; otherwise the 8.1 PDO surface. PDO_SQLSRV 5.13.1 is available on 8.3, not 8.2. | +| 8.4 | `PDO::connect()` and `Pdo\Sqlite`, `Pdo\Mysql`, `Pdo\Pgsql`, plus `Pdo\Dblib` / `Pdo\Firebird` / `Pdo\Odbc` when their profiles are enabled; historical high-bit fetch flags. | +| 8.5 | Compact fetch flags, SQLite busy/explain/transaction attributes and `setAuthorizer()`, PostgreSQL transaction-constant deprecations, and deprecations for the legacy DBLIB/Firebird/ODBC constant aliases. PDO_SQLSRV remains on `PDO` and does not add `Pdo\Sqlsrv`. | +| 8.6 | The 8.5 public surface plus PostgreSQL persistent-session cleanup with `DISCARD ALL` when the final owner releases a pooled handle. PDO_SQLSRV is omitted until Microsoft publishes an 8.6-compatible release. | + +The version switch currently governs PDO first; it does not claim that every unrelated +PHP language or standard-library difference is version-gated. ## Connecting @@ -31,20 +71,100 @@ $pg = new PDO("pgsql:host=localhost;dbname=app", "me", "secret"); // MySQL / MariaDB — credentials in the DSN or as constructor arguments. $my = new PDO("mysql:host=127.0.0.1;port=3306;dbname=app;user=me;password=secret"); $my = new PDO("mysql:host=127.0.0.1;dbname=app", "me", "secret"); + +// SQL Server / Sybase through the optional FreeTDS PDO_DBLIB profile. +$tds = new PDO("dblib:host=127.0.0.1;port=1433;dbname=app", "sa", "secret"); + +// Firebird through the optional pure-Rust wire-protocol profile. +$firebird = new PDO("firebird:dbname=localhost/3050:/data/app.fdb;charset=UTF8", "SYSDBA", "secret"); + +// Any installed unixODBC driver through the optional PDO_ODBC profile. +$odbc = new PDO("odbc:Driver={PostgreSQL Unicode};Servername=127.0.0.1;Database=app", "me", "secret"); + +// Informix through the optional Client SDK CLI/ODBC profile. +$informix = new PDO("informix:Driver={IBM INFORMIX ODBC DRIVER};Server=ol_informix;Database=app", "me", "secret"); + +// Db2 through the optional PDO_IBM CLI/ODBC profile. +$db2 = new PDO("ibm:DATABASE=SAMPLE;HOSTNAME=127.0.0.1;PORT=50000;PROTOCOL=TCPIP", "db2inst1", "secret"); + +// SQL Server through Microsoft ODBC Driver 18/17 and PDO_SQLSRV 5.13.1. +$sqlsrv = new PDO("sqlsrv:Server=127.0.0.1,1433;Database=app;Encrypt=yes", "sa", "secret"); + +// Oracle through the optional PDO_OCI / Instant Client profile. +$oracle = new PDO("oci:dbname=//127.0.0.1:1521/FREEPDB1;charset=AL32UTF8", "me", "secret"); + +// CUBRID through the optional official CCI client profile. +$cubrid = new PDO("cubrid:host=127.0.0.1;port=33000;dbname=cubdb", "dba", ""); ``` -The DSN must start with `sqlite:`, `pgsql:`, or `mysql:`. For SQLite, the -`$username` and `$password` arguments are accepted for signature compatibility -but ignored; constructor options still seed PDO attributes. For PostgreSQL and -MySQL, `$username` / `$password` are folded into the connection (other keys like -`host`, `port`, `dbname`, and — for MySQL — `unix_socket` come from the -`key=value;…` DSN). A failed connection throws a `PDOException`. +The DSN normally starts with `sqlite:`, `pgsql:`, `mysql:`, or (when enabled) +`dblib:`, `firebird:`, `odbc:`, `informix:`, `ibm:`, `sqlsrv:`, `oci:`, or `cubrid:`. A colonless value may +instead name a runtime PHP configuration alias such as +`pdo.dsn.app = "pgsql:host=db;dbname=app"`; `new PDO("app")` then uses the resolved +DSN. The standalone binary loads an explicit `PHPRC` file (or `php.ini` inside an +explicit `PHPRC` directory) followed by alphabetically sorted `.ini` fragments from +`PHP_INI_SCAN_DIR`, with the last assignment winning as in PHP. Alias names are +case-sensitive. An absent alias throws the normal argument-shaped `PDOException`; an +alias whose value contains no colon throws +`PDOException("invalid data source name (via INI: pdo.dsn.)")`. + +A colon-bearing DSN with an unknown prefix throws `PDOException("could not find +driver")` before any connection is attempted, matching php-src. The resolved alias +value is also authoritative for driver-specific subclasses, `PDO::connect()`, +credentials, and persistent-pool identity. + +For SQLite, the `$username` / `$password` arguments are accepted for signature +compatibility but ignored; constructor options still seed PDO attributes. A failed +connection throws a `PDOException` whose message carries a `SQLSTATE[…]:` prefix and +whose `$errorInfo` is a real triple (`HY000` for SQLite, `08006` for the network +drivers). + +### The `uri:` DSN + +`new PDO("uri:/etc/app/db.dsn")` reads the real DSN from the **first line** of the +referenced file, as in php-src (which deprecated the form but still supports it). +Divergences: elephc has no `file://` stream wrapper, so a `uri:file:///path` DSN has +the scheme stripped and the remainder opened as a plain path (any other scheme simply +fails to open); the trailing newline is trimmed; and no `E_DEPRECATED` is raised +(elephc has no deprecation channel). An unreadable/empty file or a first line with no +colon throws a `PDOException`. + +### Credential precedence (asymmetric, by driver — matches php-src) + +- **PostgreSQL**: a `user=` / `password=` **in the DSN wins** over the constructor + arguments; the constructor's values are only folded in when the DSN does not carry + that key. (libpq's conninfo parsing is last-wins, and php-src assembles the DSN's + keys last.) +- **MySQL / MariaDB**: the **constructor argument wins** over a DSN key. `new + PDO("mysql:host=h;user=readonly", "admin", $pw)` connects as `admin`, exactly as in + real PHP. +- **DBLIB**: like php-src, constructor credentials become the DB-Library login + credentials and take precedence over credentials embedded in the DSN. +- **CUBRID**: constructor credentials replace `user` / `password` DSN values, while + string-keyed constructor options are appended as CCI URL options in source order, + matching PDO_CUBRID's factory. + +### Persistent connections + +`PDO::ATTR_PERSISTENT` in the constructor options selects a **process-local** pool. +The pool key is the fully materialized DSN **plus** the `ATTR_PERSISTENT` value when +that value is a non-numeric, non-empty string — so two persistent connections to the +same DSN under different key strings stay distinct handles, exactly as in php-src. +Anything else (a bool, an int, a numeric string, `""`) is a plain numeric coercion and +uses the unkeyed pool; none of those forms is an error. + +```php + true]); // unkeyed pool +$b = new PDO($dsn, null, null, [PDO::ATTR_PERSISTENT => "reports"]); // separate pool +``` -Constructor options may include `PDO::ATTR_PERSISTENT => true`. Persistent PDO -instances use a process-local pool keyed by the fully materialized DSN, so a later -PDO constructed with the same DSN and persistent option reuses the existing -connection inside the same compiled program. Non-persistent connections are -opened independently. +Setting `ATTR_PERSISTENT` later with `setAttribute()` returns `false`; persistence is a +constructor-only choice and the live handle remains unchanged. Persistent connections are local to +the running native process; there is no cross-process pool. Checkout is serialized and +validates liveness (MySQL `COM_PING`, PostgreSQL client state), evicting a dead session +before reconnecting. Under the PHP 8.6 target, the final owner of a persistent PostgreSQL +handle also performs upstream's new disconnect-equivalent `DISCARD ALL` cleanup. ## Executing statements @@ -57,11 +177,14 @@ $n = $db->exec("INSERT INTO users (name, score) VALUES ('Ada', 9.5)"); echo $db->lastInsertId(); // "1" ``` +`exec("")`, `query("")`, and `prepare("")` each throw a `ValueError` before any driver +call, naming their own method (php-src does the same). + ## Prepared statements and binding `execute()` accepts an array of parameters. Positional (`?`) placeholders bind by -position; named (`:name`) placeholders bind by key (with or without the leading -colon). Bound values are typed automatically (int, float, string, null, bool). +position; named (`:name`) placeholders bind by key (with or without the leading colon). +Bound values are typed automatically (int, float, string, null, bool). ```php execute([":name" => "Bob", ":score" => 7.25]); $ins->execute(["name" => "Cyd", "score" => 3.0]); // colon optional ``` -`query()` prepares and immediately executes a statement, returning the -`PDOStatement` ready to fetch. +`query()` prepares and immediately executes a statement, returning the `PDOStatement` +ready to fetch. -Parameters can also be bound individually with `bindValue()` (and `bindParam()`), -then applied by an argument-less `execute()`: +Parameters can also be bound individually with `bindValue()` (and `bindParam()`), then +applied by an argument-less `execute()`: ```php bindValue(":score", 5, PDO::PARAM_INT); $stmt->execute(); ``` -`bindParam()` binds the variable's *current* value (it does not defer a -by-reference read to `execute()` time), so bind immediately before `execute()`. +`bindParam()` retains a durable reference to the caller variable and reads its current +value on every `execute()`, including when a concrete scalar local must be promoted to +the compiler's boxed `Mixed` reference-cell representation. PDO_OCI also writes native +`PARAM_INPUT_OUTPUT` results back through that reference, honors `$maxLength`, and turns +output LOB locators into PHP streams. + +**`execute($params)` REPLACES the recorded bindings**, it does not layer on top of +them — php-src rebuilds its bound-parameter table from the array, so a slot bound +earlier with `bindValue()` but absent from `$params` does not keep a stale value, and a +later argument-less `execute()` replays *that array*, not the earlier `bindValue()` +calls. + +### Bind validation + +- `bindValue()` / `bindParam()` / `bindColumn()` reject a positional index below 1 + (`ValueError`) and an empty named placeholder (`ValueError`), before recording + anything. +- A named placeholder the SQL never declares, or a positional index past the + placeholder count, fails `execute()` with **SQLSTATE HY093** ("Invalid parameter + number") — errmode-aware, and the statement is left un-executed so a later `fetch()` + cannot step it. +- `PDO::PARAM_*` **flags** are masked off before dispatch, so + `PDO::PARAM_INT|PDO::PARAM_INPUT_OUTPUT` still binds an integer. +- `PDO::PARAM_BOOL` uses the driver's **real boolean bind** (PostgreSQL sends `t`/`f`, + not an integer literal a `BOOL` column would refuse); the value is truthiness-reduced + first, as php-src does. +- `PDO::PARAM_LOB` binds raw bytes (embedded NUL preserved). +- `PDO::PARAM_NULL` / `PARAM_INT` / `PARAM_STR` behave as expected; `PARAM_STR` binds + with a measured byte length, so a value containing a NUL byte binds in full. + +### Quoting Prefer prepared statements over interpolation. When you must embed a string, -`PDO::quote()` wraps it in single quotes and escapes embedded quotes: +`PDO::quote()` is driver-aware: ```php quote("O'Brien"); // 'O''Brien' +$db->quote("O'Brien"); // 'O''Brien' +$db->quote($bytes, PDO::PARAM_LOB); // _binary'…' (MySQL) / '\x…' (pgsql bytea hex) ``` +- **SQLite**: `''`-doubling. Binary-safe for an embedded NUL byte — a deliberate + improvement over php-src, whose own SQLite quoter truncates at the first NUL. +- **PostgreSQL**: `''`-doubling, switching to the `E'…'` form when a backslash is + present. `PARAM_LOB` produces a `bytea` hex literal. +- **MySQL**: backslash-escapes quotes, backslashes, and control bytes — **unless** the + session's `sql_mode` has `NO_BACKSLASH_ESCAPES`, which the bridge reads live; under + that mode backslash-escaping is actively unsafe, so `quote()` falls back to + `''`-doubling only, mirroring mysqlnd. `PARAM_LOB` adds the `_binary` introducer. + ## Fetching results ```php @@ -116,10 +278,15 @@ class UserRow { public mixed $name; } -$row = $db->query("SELECT id, name FROM users")->fetch(PDO::FETCH_CLASS, UserRow::class); +// Class / object targets are configured on the STATEMENT, never passed to fetch(). +$stmt = $db->query("SELECT id, name FROM users"); +$stmt->setFetchMode(PDO::FETCH_CLASS, UserRow::class); +$row = $stmt->fetch(); $target = new UserRow(); -$same = $db->query("SELECT id, name FROM users")->fetch(PDO::FETCH_INTO, $target); +$stmt = $db->query("SELECT id, name FROM users"); +$stmt->setFetchMode(PDO::FETCH_INTO, $target); +$same = $stmt->fetch(); // fills and returns $target $all = $db->query("SELECT id FROM users")->fetchAll(PDO::FETCH_NUM); $one = $db->query("SELECT name FROM users")->fetchColumn(); // first column of next row @@ -128,15 +295,106 @@ $one = $db->query("SELECT name FROM users")->fetchColumn(); // first column of $ids = $db->query("SELECT id FROM users")->fetchAll(PDO::FETCH_COLUMN); // [1, 2, …] ``` -`fetch()` returns `false` when the result set is exhausted. `FETCH_OBJ` creates a -real `stdClass` and assigns dynamic properties directly, including numeric column -names such as `"0"`. `FETCH_CLASS` creates the requested class and assigns column -values to matching declared or dynamic properties; `FETCH_INTO` fills and returns -the object instance passed as the second argument. +`fetch()` has **php-src's signature** — `fetch(int $mode = PDO::FETCH_DEFAULT, int +$cursorOrientation = PDO::FETCH_ORI_NEXT, int $cursorOffset = 0)`. Its second parameter +is an int **cursor orientation**, *not* a class/object target. (An earlier elephc +release accepted `fetch(PDO::FETCH_CLASS, Row::class)`; that idiom is a `TypeError` in +real PHP and no longer works here. Use `setFetchMode()` or `fetchObject()`.) The +orientation is honored by PostgreSQL statements prepared with +`[PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]`. SQLite rejects a scroll cursor and MySQL +remains forward-only, matching the capabilities exposed by those drivers. + +`fetch()` returns `false` when the result set is exhausted. `FETCH_OBJ` creates a real +`stdClass` and assigns dynamic properties directly, including numeric column names such +as `"0"`. `FETCH_CLASS` builds the configured class (or `stdClass` when none is +configured) and assigns column values to matching declared or dynamic properties; +`FETCH_INTO` fills and returns the configured object, and raises **HY000** ("No +fetch-into object specified.") when there is none. Column values are returned with their native scalar shape: integer → int, real / -floating point → float, text → string, binary/BLOB/`bytea` → string with embedded -NUL bytes preserved, and `NULL` → null. `FETCH_BOTH` is the default mode. +floating point → float, text → string, SQLite/MySQL binary values → binary-safe string, +PostgreSQL `boolean` → bool, PostgreSQL `bytea` → rewound stream resource, and `NULL` → +null. `FETCH_BOTH` is the default mode. + +### Fetch modes + +| Mode | Notes | +| --- | --- | +| `FETCH_ASSOC` / `FETCH_NUM` / `FETCH_BOTH` / `FETCH_OBJ` | Fully supported. | +| `FETCH_COLUMN` | Column index is the 2nd argument to `setFetchMode()` / `fetchAll()`. | +| `FETCH_CLASS` | Target class configured on the statement. Properties are assigned before the constructor by default; `FETCH_PROPS_LATE` selects constructor-first hydration. | +| `FETCH_INTO` | Target object configured on the statement; HY000 without one. | +| `FETCH_KEY_PAIR` | Two-column result as `[col0 => col1]`; HY000 if the result has ≠ 2 columns. | +| `FETCH_NAMED` | Assoc-only; duplicate column names group into a list under one key. | +| `FETCH_BOUND` | Advances the cursor, writes every `bindColumn()` destination, and returns `true`/`false`. | +| `FETCH_CLASSTYPE` | **Real**: the class name comes from column 0's *value*, per row; column 0 is consumed; an unknown class falls back to `stdClass`. | +| `FETCH_GROUP` / `FETCH_UNIQUE` | **Implemented** (see below). | +| `FETCH_PROPS_LATE` | Implements PHP's constructor-first class hydration. | +| `FETCH_LAZY` | `fetch()` returns the statement-owned reusable `PDORow`; `fetchAll()` rejects it as php-src does. | +| `FETCH_FUNC` | `fetchAll()` invokes any PHP callable shape (closure, function string, callable array, first-class descriptor, invokable object) once per row. | + +`setFetchMode()` validates before storing anything, so a rejected call leaves the +statement's previous mode intact: an out-of-range base mode is a `ValueError`, +`FETCH_COLUMN` with a non-int index is a `TypeError` (a numeric *string* is rejected too, +as in php-src, where the argument is variadic and never juggled) and with a negative index +a `ValueError`, `FETCH_COLUMN`/`FETCH_CLASS`/`FETCH_INTO` with no second argument raises a +`ValueError` carrying php-src's ArgumentCountError text (elephc has no +`ArgumentCountError` class), and `FETCH_CLASS|FETCH_CLASSTYPE` *with* an explicit class +argument is rejected as the contradiction it is. The OR-able high-bit flags are masked off +before every one of those gates, so `FETCH_CLASS|FETCH_PROPS_LATE` is checked (and stores +its class) exactly like a bare `FETCH_CLASS`. + +`fetchColumn($n)` throws a `ValueError` for a negative index, and for an index past the +column count of the row it just fetched — but an exhausted result set still just returns +`false`, as in real PHP. + +### Column metadata + +`getColumnMeta($i)` returns `false` for a statement that has not been executed and for an +index at or past the column count; a **negative** index is a `ValueError` (php-src +validates the argument before any driver dispatch). + +- **SQLite** reports the runtime **storage class**, exactly as php-src's `pdo_sqlite` + does: `native_type` is `"integer"` / `"double"` / `"string"` / `"null"`, a BLOB column + reports `native_type` `"string"` with `"blob"` pushed into `flags` and `pdo_type` + `PARAM_STR`. The column's *declared* type (`sqlite3_column_decltype`) is a separate + **`sqlite:decl_type`** key, present only when the column has one. Common descriptor + fields report `len = -1` and `precision = 0`; `table` is present only for a column + backed by a native SQLite table. +- **PostgreSQL** reports `pgsql:oid`, `pgsql:table_oid`, native type, PDO type, raw + `PQfsize`/`PQfmod` equivalents, and the `pg_class` table name when applicable. +- **MySQL** reports its wire type (`LONG`, `VAR_STRING`, `NEWDECIMAL`, …), PDO type, + source table, declared length/precision, and native flags such as `not_null`, + `primary_key`, `multiple_key`, `unique_key`, and `blob`. +- **DBLIB** reports php-src's exact DB-Library descriptor keys: `max_length`, + `precision`, `scale`, `column_source`, `native_type`, `native_type_id`, + `native_usertype_id`, and `pdo_type`. + +### `FETCH_GROUP` and `FETCH_UNIQUE` + +Both consume **column 0** as the key and exclude it from the row: + +```php + [ [name…], [name…] ], …] — every row that carried that key +$byType = $db->query("SELECT type, name, id FROM t")->fetchAll(PDO::FETCH_GROUP|PDO::FETCH_ASSOC); + +// [type => [name, name, …]] — FETCH_COLUMN under GROUP defaults the value column to 1 +$names = $db->query("SELECT type, name FROM t")->fetchAll(PDO::FETCH_GROUP|PDO::FETCH_COLUMN); + +// [id => row] — last write wins, exactly like php-src's plain overwrite +$byId = $db->query("SELECT id, name FROM t")->fetchAll(PDO::FETCH_UNIQUE|PDO::FETCH_ASSOC); +``` + +Groups come out in **first-seen key order**, and `FETCH_NUM`/`FETCH_BOTH` rows are +re-indexed from 0 after the key column is removed — both matching php-src. + +Supported base modes under GROUP/UNIQUE: `FETCH_ASSOC`, `FETCH_NUM`, `FETCH_BOTH`, +`FETCH_OBJ`, `FETCH_COLUMN`, `FETCH_CLASS`. Any other base mode, and the +`FETCH_CLASSTYPE` combination (which would want column 0 too), raise a `PDOException` +rather than returning a plausible array of the wrong shape. + +Grouping keys use PHP array-key normalization, including integer-looking strings. ## Iterating a statement @@ -153,50 +411,565 @@ foreach ($stmt as $i => $row) { } ``` -The cursor is forward-only: each row is consumed as it is yielded, so a statement -can be iterated once. +The cursor is forward-only: each row is consumed as it is yielded, so a statement can +be iterated once. `PDOStatement` implements `IteratorAggregate` and `getIterator()` returns +a forwarding iterator. The compiler-owned adapter is excluded from public class discovery, +has a private constructor and exposes no public helper hook; the observable interface +relationship matches PHP. + +## Attributes + +`getAttribute()` / `setAttribute()` act on: + +| Attribute | Behavior | +| --- | --- | +| `ATTR_ERRMODE` | Silent / Warning / Exception (default). | +| `ATTR_DRIVER_NAME` | `"sqlite"`, `"pgsql"`, `"mysql"`, or an enabled optional driver name. | +| `ATTR_PERSISTENT` | Pool selection (constructor only, in practice). | +| `ATTR_TIMEOUT` | Seconds. SQLite: busy-timeout. pgsql/mysql: initial connect timeout. DBLIB: login and query timeout unless a DBLIB-specific timeout overrides it. | +| `ATTR_DEFAULT_FETCH_MODE` | Mode used by a no-argument `fetch()`; inherited by statements at `prepare()` time. | +| `ATTR_SERVER_VERSION` | The server's version string for the default drivers, Firebird, and ODBC. DBLIB follows IM001 and exposes negotiated TDS through its driver-specific attribute. | +| `ATTR_CLIENT_VERSION` | SQLite's embedded library version; PostgreSQL/MySQL/Firebird report their statically linked client; ODBC reports `ODBC-unixODBC`. DBLIB follows IM001 and exposes FreeTDS through its driver-specific attribute. | +| `ATTR_SERVER_INFO` | PostgreSQL: live PID/session parameters. MySQL: live server statistics. Firebird: version information. ODBC: DBMS name. SQLite follows IM001. | +| `ATTR_CONNECTION_STATUS` | PostgreSQL: live connected/closed status in libpq's wording. MySQL: actual TCP/socket transport description. Firebird: boolean liveness. SQLite follows IM001. | +| `ATTR_CASE` | Folds fetched column-name keys upper/lowercase. | +| `ATTR_ORACLE_NULLS` | Folds `NULL` ↔ `""` in fetched scalar values. | +| `ATTR_STRINGIFY_FETCHES` | Stringifies fetched INTEGER/FLOAT values. | +| `ATTR_EMULATE_PREPARES` | MySQL text protocol (default `true`) or PostgreSQL simple-query protocol (default `false`). DBLIB is read-only `true`, because DB-Library has no native prepare API. SQLite rejects it. | +| `ATTR_AUTOCOMMIT` | Live MySQL or ODBC autocommit state. | +| `ATTR_DEFAULT_STR_PARAM` | MySQL and DBLIB default `PARAM_STR_CHAR`/`PARAM_STR_NATL` string binding. | +| `Pdo\Dblib::ATTR_CONNECTION_TIMEOUT` | Constructor-only DB-Library login timeout in seconds. | +| `Pdo\Dblib::ATTR_QUERY_TIMEOUT` | Constructor and live query timeout in seconds; write-only, matching php-src. | +| `Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER` | Returns SQL Server `uniqueidentifier` values as uppercase canonical strings instead of 16 raw bytes. | +| `Pdo\Dblib::ATTR_VERSION` / `ATTR_TDS_VERSION` | FreeTDS client version and negotiated TDS protocol version (read-only). | +| `Pdo\Dblib::ATTR_SKIP_EMPTY_ROWSETS` | Omits DB-Library results without columns while traversing `nextRowset()`. | +| `Pdo\Dblib::ATTR_DATETIME_CONVERT` | Selects FreeTDS text conversion; disabled uses php-src's fixed `YYYY-MM-DD HH:MM:SS` representation. | +| `Pdo\Firebird::ATTR_DATE_FORMAT` / `ATTR_TIME_FORMAT` / `ATTR_TIMESTAMP_FORMAT` | `strftime`-style output formats for Firebird temporal values. | +| `Pdo\Firebird::TRANSACTION_ISOLATION_LEVEL` | `READ_COMMITTED`, `REPEATABLE_READ` (default), or `SERIALIZABLE` for the next manual transaction. | +| `Pdo\Firebird::WRITABLE_TRANSACTION` | Selects read/write (default) or read-only manual transactions. | +| `Pdo\Odbc::ATTR_USE_CURSOR_LIBRARY` | Constructor-only ODBC driver-manager cursor selection. | +| `Pdo\Odbc::ATTR_ASSUME_UTF8` | Live ODBC UTF-8 conversion flag. | +| `PDO::SQLSRV_ATTR_*` | PDO_SQLSRV encoding, timeout, direct-query, cursor, buffering, numeric/datetime fetch, decimal formatting, and data-classification controls. | +| `Pdo\Sqlite::ATTR_OPEN_FLAGS` | Raw `sqlite3_open_v2` flags at open time. A `file:` DSN body always gets `SQLITE_OPEN_URI` OR-ed in. | +| `Pdo\Sqlite::ATTR_READONLY_STATEMENT` | Live `sqlite3_stmt_readonly()` read (statement-level). | +| `Pdo\Sqlite::ATTR_EXTENDED_RESULT_CODES` | Wired: with it on, `errorInfo()[1]` is the *extended* code (`2067` SQLITE_CONSTRAINT_UNIQUE, not the coarse `19`). Write-only, exactly as in php-src — `getAttribute()` follows IM001. | +| `Pdo\Mysql::ATTR_INIT_COMMAND` | One SQL statement run right after authentication. | +| `Pdo\Mysql::ATTR_FOUND_ROWS` | Wired: negotiates `CLIENT_FOUND_ROWS`, so an UPDATE's `rowCount()` reports rows *matched*, not rows *changed*. | +| `Pdo\Mysql::ATTR_DIRECT_QUERY` | Alias of `ATTR_EMULATE_PREPARES`, as in php-src. | +| `Pdo\Mysql::ATTR_USE_BUFFERED_QUERY` | Selects buffered or observable unbuffered semantics for new statements. | +| `Pdo\Mysql::ATTR_LOCAL_INFILE` / `ATTR_LOCAL_INFILE_DIRECTORY` | Constructor-only upload permission and canonical directory sandbox. | +| `Pdo\Mysql::ATTR_COMPRESS` / `ATTR_IGNORE_SPACE` / `ATTR_MULTI_STATEMENTS` | Connection protocol/capability controls. | +| `Pdo\Mysql::ATTR_SSL_KEY` / `ATTR_SSL_CERT` / `ATTR_SSL_CA` / `ATTR_SSL_VERIFY_SERVER_CERT` | Drive MySQL TLS (see the TLS section). | +| `Pdo\Pgsql::ATTR_DISABLE_PREPARES` | Uses PostgreSQL's execute-only simple-query path, including multi-command SQL. | +| `ATTR_PREFETCH` | PostgreSQL connection/prepare option controlling buffered cursor semantics. | +| `ATTR_CURSOR` (prepare option) | PostgreSQL supports `CURSOR_SCROLL` and all `FETCH_ORI_*` movements. SQLite rejects non-forward cursors; MySQL remains forward-only. | +| `ATTR_FETCH_TABLE_NAMES` | MySQL prefixes fetched column keys with the protocol table label; other drivers reject it. | + +`ATTR_CASE`, `ATTR_ORACLE_NULLS`, `ATTR_STRINGIFY_FETCHES`, and +`ATTR_DEFAULT_FETCH_MODE` are **snapshotted onto each statement at `prepare()` time**, +not re-read on every fetch: a `setAttribute()` call after a statement is prepared does +not retroactively affect it (real PHP re-checks the connection attribute per fetch). + +Attributes are selected by the active driver's hook, not by numeric-range membership. +An attribute unsupported by that driver is never retained in a generic echo bag: +`setAttribute()` returns `false`, and `getAttribute()` follows the IM001 path. This is +especially important because driver-specific values overlap (`1002` is SQLite extended +result codes and MySQL init command). + +### Attribute value validation + +- The **shape** of the value is checked before any range check, exactly as php-src's + `pdo_get_long_param()` / `pdo_get_bool_param()` do: `setAttribute(PDO::ATTR_ERRMODE, + "banana")` raises a `TypeError` instead of casting to `0` and silently switching the + connection to `ERRMODE_SILENT`. The same check runs on the constructor's `$options` + array. +- `ATTR_ERRMODE` outside 0/1/2, and `ATTR_CASE` outside `CASE_NATURAL`/`UPPER`/`LOWER`, + raise a `ValueError` and leave the current value untouched. `ATTR_DEFAULT_FETCH_MODE` + rejects `0`. +- An unsupported attribute, whether it is a known constant or an unknown number, + makes `setAttribute()` return **`false` silently**. `getAttribute()` raises **IM001** + (or returns `false` in silent mode), matching the active php-src driver hook. + +`PDOStatement::getAttribute()` answers SQLite readonly/busy/explain state, +PostgreSQL result-memory size, and `ATTR_EMULATE_PREPARES` from the prepare-time +snapshot. SQLite explain mode is writable on PHP 8.5+. Other statement attributes follow +the driver's IM001 path. ## PostgreSQL notes -The PostgreSQL driver behaves like the SQLite one, with a few database-specific -points: - -- **Placeholders.** PDO `?` and `:name` placeholders are translated to - PostgreSQL's native `$1, $2, …` at prepare time, so you write the same - portable SQL for either driver. -- **`lastInsertId()`.** PostgreSQL has no rowid; `lastInsertId()` returns the - session's last sequence value (`lastval()`), or `lastInsertId($sequence)` - returns `currval($sequence)`. Use a `SERIAL`/`IDENTITY` column or `RETURNING`. -- **Types.** `integer`/`bigint` → int, `real`/`double precision` → float, - `boolean` → `0`/`1`, text types → string, `NULL` → null. The rich types are - returned as their text representation: `numeric`/`decimal` (scale preserved), - `date` / `time` / `timestamp` / `timestamptz`, `uuid`, and `json`/`jsonb`. The - same values bind as parameters (text is coerced to the column type). `bytea` - is returned as a PHP string with embedded NUL bytes preserved. `json` / `jsonb` - are re-serialized compactly, so whitespace may differ from the server's text - output, but the value is equivalent. Other types (arrays, network types) are - best read with an explicit `::text` cast. +The PostgreSQL driver behaves like the SQLite one, with a few database-specific points: + +- **Placeholders.** PDO `?` and `:name` placeholders are translated to PostgreSQL's + native `$1, $2, …` at prepare time, so you write the same portable SQL for either + driver. The scanner skips `--` / `/* */` comments, `'…'` / `"…"` quoted regions, + `$tag$…$tag$` dollar-quoted strings (including **non-ASCII tags**, e.g. `$café$…$café$`), + the `::type` cast operator (including greedy runs of three or more colons), and the + `??` jsonb operator. +- **`lastInsertId()`.** PostgreSQL has no rowid; `lastInsertId()` returns the session's + last sequence value (`lastval()`), or `lastInsertId($sequence)` returns + `currval($sequence)`. Use a `SERIAL`/`IDENTITY` column or `RETURNING`. +- **Types.** `integer`/`bigint` → int, `real`/`double precision` → float, `boolean` → + bool, text types → string, `NULL` → null. The rich types are returned as their text + representation: `numeric`/`decimal` (scale preserved), `date` / `time` / `timestamp` / + `timestamptz`, `uuid`, and `json`/`jsonb`. The same values bind as parameters (text is + coerced to the column type). `bytea` is returned as a rewound binary stream resource. + `json` / `jsonb` are re-serialized compactly, so whitespace may differ + from the server's text output, but the value is equivalent. Other types (arrays, network + types) are best read with an explicit `::text` cast. +- **`getColumnMeta()`** reports PostgreSQL's real per-column metadata, read off the + prepared statement's column descriptors (so it is valid before any row is fetched): + `native_type` (the server's `pg_type.typname`: `int4`, `bool`, `bytea`, `text`, …), + `pdo_type` (php-src's OID switch exactly: `BOOL`→`PARAM_BOOL`, `INT2`/`INT4`/`INT8`→ + `PARAM_INT`, `BYTEA`/`OID`→`PARAM_LOB`, everything else `PARAM_STR`), `pgsql:oid` + (`PQftype`), `pgsql:table_oid` (`PQftable`, emitted unconditionally — `0` is + `InvalidOid`, the server's own answer for an expression/literal/aggregate column), + `len` (`PQfsize`) and `precision` (`PQfmod`). The last two are **raw and + counter-intuitive, exactly as in real PDO**: `len` is the type's fixed byte width + (`int4` → 4, `uuid` → 16) and **`-1` for any VARLENA** (text, varchar, numeric, bytea, + json, arrays) — a `VARCHAR(20)` reports `len` `-1`, not `20`; its declared 20 surfaces + in `precision` as the **undecoded atttypmod** `24` (20 + VARHDRSZ), and `NUMERIC(10,2)` + is `655366`. A plain table column also carries its `pg_class` relation name under + `table`; expression columns omit that key. +- **`getNotify()`.** `getNotify(PDO::FETCH_ASSOC, $timeoutMs)` shapes a pending + `LISTEN`/`NOTIFY` message as `["message" => $channel, "pid" => $pid, "payload" => + $payload]`; any other `$fetchMode` (the default) keeps the numerically-indexed + `[$channel, $pid, $payload]` shape. Both return `false` when no notification + arrives within the timeout. +- **`COPY`.** `copyFromArray()` / `copyFromFile()` emit `COPY … FROM STDIN`; + `copyToArray()` / `copyToFile()` emit `COPY … TO STDOUT`. The `$separator` is + **truncated to its first byte** (PostgreSQL's COPY grammar admits only a one-byte + delimiter, and php-src's builders dereference exactly one byte), so + `copyFromArray(…, "::")` copies with `:` rather than failing — matching real PDO. + `copyToArray()` distinguishes an empty table (`[]`) from a transport error (`false`). +- **Connect timeout.** A `connect_timeout` the DSN (or `PDO::ATTR_TIMEOUT`) supplies + wins; when neither does, **30 s** is appended — php-src's own default. Without it the + pure-Rust client has no application-level connect timeout and hangs for minutes on a + black-holed host. ## MySQL / MariaDB notes The MySQL driver behaves like the others, with a few database-specific points: -- **Placeholders.** MySQL uses positional `?` natively; PDO `:name` placeholders - are rewritten to `?` at prepare time (a name reused in the statement binds the - same value to each position), so you write the same portable SQL for either - driver. As in PHP, a single statement uses either `?` or `:name`, not both. +- **Placeholders.** MySQL uses positional `?` natively; PDO `:name` placeholders are + rewritten to `?` at prepare time (a name reused in the statement binds the same value + to each position), so you write the same portable SQL for either driver. As in PHP, a + single statement uses either `?` or `:name`, not both. The scanner skips `--` / `#` / + `/* */` comments and `'…'` / `"…"` / `` `…` `` quoted regions, and is handed the + connection's **live `NO_BACKSLASH_ESCAPES`** state so its idea of where a string + literal ends always agrees with the server's. - **`lastInsertId()`.** Returns the last `AUTO_INCREMENT` value; the sequence-name argument (a PostgreSQL/Oracle concept) is ignored. -- **Transactions.** Wrap DML on transactional (InnoDB) tables. MySQL implicitly - commits around DDL (`CREATE`/`DROP TABLE`, …), so a `beginTransaction()` cannot - roll those back. -- **Types.** `INT`/`BIGINT`/`BOOLEAN` (a `TINYINT(1)`, so `0`/`1`) → int, - `FLOAT`/`DOUBLE` → float, text types → string, `NULL` → null. The rich types are - returned as their text representation: `DECIMAL` (scale preserved), `DATE`, - `DATETIME` / `TIMESTAMP`, and `TIME`. The same values bind as parameters (text - is coerced to the column type by the server). Binary and BLOB columns are - returned as PHP strings with embedded NUL bytes preserved. -- **Driver name.** `getAttribute(PDO::ATTR_DRIVER_NAME)` reports `"mysql"`. +- **Transactions.** Wrap DML on transactional (InnoDB) tables. MySQL implicitly commits + around DDL (`CREATE`/`DROP TABLE`, …), so a `beginTransaction()` cannot roll those back. +- **Types.** `INT`/`BIGINT`/`BOOLEAN` (a `TINYINT(1)`, so `0`/`1`) → int, `FLOAT`/`DOUBLE` + → float, text types → string, `NULL` → null. The rich types are returned as their text + representation: `DECIMAL` (scale preserved), `DATE`, `DATETIME` / `TIMESTAMP`, and + `TIME`. The same values bind as parameters (text is coerced to the column type by the + server). Binary and BLOB columns are returned as PHP strings with embedded NUL bytes + preserved. A `BIGINT UNSIGNED` value above `PHP_INT_MAX` is returned as its exact + decimal numeric string (matching PHP) rather than wrapping negative. +- **`getColumnMeta()`'s `native_type`** is MySQL's own wire-type name, as in php-src + (`type_to_name_native`): an `INT` column is `"LONG"`, a `VARCHAR` is `"VAR_STRING"`, a + `DECIMAL` is `"NEWDECIMAL"`, a `BLOB`/`TEXT` is `"BLOB"`. `pdo_type`, `len`, + `precision` and `flags` still come from the generic storage-class derivation. +- **`Pdo\Mysql::ATTR_INIT_COMMAND`.** Passed as a constructor option, this SQL statement + runs on the server immediately after authentication (e.g. `SET NAMES utf8mb4`). +- **`Pdo\Mysql::ATTR_FOUND_ROWS`.** Wired: negotiates `CLIENT_FOUND_ROWS` in the + handshake, so an `UPDATE` writing a value a row already holds reports `rowCount()` 1 + (matched) rather than 0 (changed). +- **`charset` DSN key.** A `mysql:…;charset=utf8mb4` DSN key becomes its own `SET NAMES + utf8mb4` statement at connect time, run before `ATTR_INIT_COMMAND` (so an explicit init + command can still issue its own `SET NAMES`). Only plain identifier characters + (`[A-Za-z0-9_]`) are honored; anything else is silently dropped. +- **`unix_socket` DSN key.** Honored **only** when the DSN names no host or names exactly + `localhost` — php-src's own condition (a literal `strcmp("localhost", …)`), so + `mysql:host=127.0.0.1;unix_socket=/tmp/mysql.sock` deliberately connects over TCP. +- **Connect timeout.** Defaults to **30 s** (php-src's `PDO::ATTR_TIMEOUT` default) when + neither the DSN's `connect_timeout` key nor `ATTR_TIMEOUT` supplies one. + +## FreeTDS PDO_DBLIB notes + +PDO_DBLIB is an optional system-client profile because php-src itself delegates this +driver to DB-Library. Install FreeTDS (`brew install freetds` on macOS or +`apt install freetds-dev` on Debian/Ubuntu), then compile with: + +```bash +cargo run --features pdo-dblib -- app.php +``` + +The profile makes `dblib:` available through `PDO::getAvailableDrivers()`, enables +the legacy `PDO::DBLIB_ATTR_*` constants on every supported PHP compatibility target, +and adds `Pdo\Dblib` on PHP 8.4+. PHP 8.5 marks the legacy aliases deprecated and +points callers to the namespaced constants, matching php-src's stubs. + +- **DSN.** `host`, `dbname`, `charset`, `appname`, `user`, and `password` follow + PDO_DBLIB. `port` is accepted as an elephc convenience extension for local and CI + instances that do not use a `freetds.conf` server alias; the bridge passes it to + `dbopen()` with FreeTDS's documented `servername:port` override syntax. +- **Prepared statements.** DB-Library has no native prepare API. Placeholders are + scanned and safely rendered as T-SQL literals; mixed named/positional styles and + missing binds fail with HY093. `ATTR_EMULATE_PREPARES` is consequently fixed at + `true` and attempting to disable it returns `false`. +- **Results.** Every `dbresults()` result is materialized and exposed through + `nextRowset()`. `ATTR_SKIP_EMPTY_ROWSETS` controls whether results without columns + remain visible. Integer/float/binary types retain their PHP scalar shapes; + `uniqueidentifier` and datetime conversion follow the driver attributes above. +- **Native diagnostics.** FreeTDS client callbacks and SQL Server/Sybase messages + populate the same SQLSTATE/native-code/message triples as the other bridge drivers. +- **Targets.** The Rust backend is target-neutral; each supported target must provide + a target-compatible `libsybdb`. macOS linking deliberately resolves FreeTDS before + `libSystem`, whose unrelated Berkeley DB API exports the same `dbopen` symbol. + +## PDO_FIREBIRD notes + +Enable Firebird without a system `libfbclient` dependency: + +```bash +cargo run --features pdo-firebird -- app.php +``` + +The profile registers `firebird:`, the three historical `PDO::FB_ATTR_*` format +aliases on PHP 8.0–8.6, and `Pdo\Firebird` on PHP 8.4+. PHP 8.5 deprecates only +the legacy aliases. `Pdo\Firebird::getApiVersion()` reports API level 40, matching +the Firebird 4/5 client API targeted by the backend. + +- **DSN.** `dbname`, `charset`, `role`, `dialect`, `user`, and `password` match + PDO_FIREBIRD. Both documented legacy remote names + (`host/port:/path-or-alias`) and Firebird 3+ `inet[4|6]://` names are accepted. +- **Values and binding.** Positional and named placeholders are normalized to the + Firebird positional protocol; mixing styles or omitting a bind fails with HY093. + Integer, floating, boolean, binary, text, and date/time values retain PDO scalar + shapes and embedded NUL bytes. +- **Transactions.** Manual transactions use the configured isolation/access mode. + Firebird's connection-level format, autocommit, table-name, isolation, and + writable attributes are live and version-independent like php-src. +- **Metadata.** Firebird follows php-src's deliberately small `getColumnMeta()` + result and returns only `pdo_type`. Statement cursor names expose the same + 31-byte validation and nullable readback contract. +- **Client identity.** `ATTR_CLIENT_VERSION` identifies `rsfbclient-rust 0.27` + rather than a dynamically installed `libfbclient`; server behavior and protocol + results remain the authoritative compatibility boundary. + +## PDO_ODBC notes + +Install unixODBC and the database-specific ODBC driver, then enable the profile: + +```bash +brew install unixodbc # macOS +sudo apt install unixodbc-dev # Debian/Ubuntu build dependency +cargo run --features pdo-odbc -- app.php +``` + +The profile follows php-src's architecture: PDO calls the ODBC 3 driver-manager ABI, +and the installed driver owns the database protocol. It exposes `PDO_ODBC_TYPE` +(`"unixODBC"`), the historical `PDO::ODBC_*` aliases on PHP 8.0–8.6, and +`Pdo\Odbc` on PHP 8.4+. PHP 8.5 deprecates the aliases in favor of the namespaced +constants. + +- **DSNs.** `odbc:` calls `SQLConnect`; a body containing `=` is + passed to `SQLDriverConnect`. Braced values preserve embedded semicolons and escaped + closing braces. Constructor credentials are appended only when the direct string + does not already contain `UID=` / `PWD=`, matching PDO_ODBC. +- **Values and metadata.** Non-NULL result values are fetched as strings, including + numeric database values. `getColumnMeta()` returns only `pdo_type => PDO::PARAM_STR`, + matching php-src's deliberately small descriptor. +- **Attributes.** Autocommit and `Pdo\Odbc::ATTR_ASSUME_UTF8` are live connection + attributes. `ATTR_USE_CURSOR_LIBRARY` is a pre-connect option. Cursor names, + native scroll-cursor selection, and `nextRowset()` use the statement's ODBC handle; + fetched scroll rows are materialized before PDO orientation is applied. +- **Unsupported hooks.** PDO_ODBC has no quoter, last-insert-id hook, or connection- + status attribute; these paths raise the same IM001 class as php-src. +- **Targets.** Every supported target links its native unixODBC library (`libodbc`). + The selected database driver must exist for that target and be registered with the + driver manager. + +## PDO_INFORMIX notes + +PDO_INFORMIX remains a PECL extension and requires IBM/HCL Client SDK. Install +the target-compatible SDK and register its ODBC driver, then enable the profile: + +```bash +cargo run --features pdo-informix -- app.php +``` + +The implementation tracks stable PECL PDO_INFORMIX 1.3.7. That extension does +not declare driver-specific constants or a `Pdo\Informix` subclass, including +on PHP 8.4+, so elephc deliberately exposes neither one. `informix:` is present +in `pdo_drivers()` and `PDO::getAvailableDrivers()` on every supported PHP +compatibility target. + +- **DSNs.** `informix:` uses `SQLConnect`; a body containing + `=` uses `SQLDriverConnect`. Constructor credentials are added only when the + connection string does not already provide them. +- **LOB compatibility.** After connecting, the bridge enables + `SQL_INFX_ATTR_LO_AUTOMATIC` followed by `SQL_INFX_ATTR_ODBC_TYPES_ONLY`, in + the same order as PECL, so Informix CLOB/BLOB values are exposed through the + standard ODBC long text/binary types. +- **Values and parameters.** Scalar result values use the Client SDK's text + representation. Binary data preserves embedded NUL bytes. Scalar + `PDO::PARAM_INPUT_OUTPUT` binds use native `SQL_PARAM_INPUT_OUTPUT`; Informix + LOB parameters remain input-only, matching the extension. +- **Driver behavior.** Natural column names are upper-cased by default. + Autocommit, transactions, native diagnostics, scroll cursors, cursor names, + multiple rowsets, and the most recent `SERIAL` value are wired through CLI. + `ATTR_CLIENT_VERSION` is `1.3.7`; `ATTR_SERVER_INFO` is the DBMS name. + PDO_INFORMIX does not implement `ATTR_SERVER_VERSION` or a connection-status + attribute, so those requests follow PDO's IM001 path. +- **Metadata.** `getColumnMeta()` follows PECL's associative shape: `scale`, the + optional base `table`, `native_type`, boolean `not_null`/`unsigned`/ + `auto_increment` flag entries, and the PHP-version-aware `pdo_type`, followed + by PDO core's common `name`, `len`, and `precision` fields. Informix + binary and long-value fetches are streams, while metadata uses `PARAM_LOB` + only for Informix BLOB/CLOB UDTs, preserving the extension's own distinction. +- **Targets.** The Rust bridge and unixODBC ABI build on macOS ARM64, Linux ARM64, + and Linux x86_64. A live connection additionally requires an IBM/HCL Client + SDK and Informix ODBC driver built for that same target. + +## PDO_IBM notes + +PDO_IBM remains a PECL extension and requires an IBM Db2 or Informix CLI/ODBC +driver registered with the target's driver manager: + +```bash +cargo run --features pdo-ibm -- app.php +``` + +The implementation tracks stable PECL PDO_IBM 1.7.0. Its seven historical +`PDO::SQL_ATTR_*` constants remain available on PHP 8.0–8.6. `Pdo\Ibm` and its +shorter `ATTR_*` spellings exist from PHP 8.4; PHP 8.5 deprecates only the +historical aliases. IBM i/PASE-only `I5_*` constants are deliberately absent +because elephc's supported targets are macOS and Linux. + +- **DSNs.** `ibm:` uses `SQLConnect`; a body containing `=` + uses `SQLDriverConnect`. Constructor credentials are appended only when the + direct connection string does not already contain them. +- **Attributes.** Autocommit is live. Info user/account/application/workstation + strings and trusted-context user/password are writable; the corresponding + readable attributes follow PDO_IBM, including its disabled trusted-context + fallthrough to the trusted user ID. Trusted context enablement is applied + before connect from the constructor options array. +- **Values and parameters.** Ordinary scalar values use CLI's text representation. + Binary, XML, BLOB, and CLOB columns are exposed as streams. Native scalar + input/output parameters are supported, while `PARAM_LOB` remains input-only + like PDO_IBM. +- **Execution.** Transactions, native diagnostics, scroll cursors, cursor names, + multiple rowsets, and Db2 `IDENTITY_VAL_LOCAL()` last-insert IDs use the CLI + handle. IDS connections retain their latest serial value. Natural result-column + names are upper-cased by default, as in PDO_IBM's handle factory. +- **Metadata.** `getColumnMeta()` reports `scale`, optional `table`, `native_type`, + associative boolean flags, `pdo_type`, and PDO core's `name`, `len`, and + `precision`. The upstream 1.7.0 BOOLEAN/BIT switch fallthrough to `PARAM_LOB` + is intentionally preserved for exact compatibility. +- **Connection information.** `ATTR_CLIENT_VERSION` reports `1.7.0` and + `ATTR_SERVER_INFO` returns the DBMS name. On supported non-PASE targets, + PDO_IBM does not expose `ATTR_SERVER_VERSION` or connection status. +- **Targets.** The bridge builds on all three supported targets through unixODBC. + A live connection additionally needs a compatible IBM CLI/ODBC driver; IBM's + proprietary client is not redistributed by elephc or public CI images. + +## PDO_SQLSRV notes + +PDO_SQLSRV is optional and requires Microsoft ODBC Driver 18 or 17 plus the +platform ODBC driver manager: + +```bash +cargo run --features pdo-sqlsrv -- app.php +``` + +The profile tracks Microsoft Drivers for PHP for SQL Server 5.13.1. That release +supports PHP 8.3, 8.4, and 8.5; elephc omits the driver from other compatibility +targets. Microsoft still declares all SQLSRV constants directly on `PDO` and does +not declare a `Pdo\Sqlsrv` class, so elephc deliberately does the same. + +- **DSNs and authentication.** `sqlsrv:Server=…` options are passed through + `SQLDriverConnectW`; when `Driver` is absent the newest installed Microsoft ODBC + Driver 18/17 is selected. Constructor credentials become `UID`/`PWD`. + `AccessToken` uses the native `SQL_COPT_SS_ACCESS_TOKEN` structure and rejects + simultaneous username, password, or `Authentication`, matching Microsoft. On + Unix-like supported targets `ConnectionPooling` follows PDO_SQLSRV by deferring + to the driver manager's `ODBCINST.INI` setting. +- **Statements.** Native prepares use UTF-16 ODBC calls. Direct-query mode skips + `SQLPrepare` and executes the marker-bearing SQL directly; emulated mode renders + T-SQL literals with `N'…'` or `0x…` binary syntax. Positional/named binds, + scalar input/output parameters, query timeouts, scroll/keyset/dynamic/static and + client-buffered cursors, and multiple rowsets use the Microsoft statement handle. + As upstream does, `PDO::CURSOR_SCROLL` initially selects an ODBC static cursor, + while `SQLSRV_CURSOR_BUFFERED` switches the native handle back to forward-only + before `SQLPrepare` and provides scrolling from the bridge's bounded client-side + result buffer. + Prepare-time attributes 1000–1009 are applied in their fixed Microsoft driver + order and stop on the first invalid option. Repeated prepares safely probe the + heterogeneous option hash whether an entry uses its concrete runtime scalar tag + or an already-boxed Mixed cell. + Placeholder scanning follows php-src's generic PDO rules, so `#name` remains a + SQL Server temporary-table identifier and does not hide later bind markers. + Successful zero-column DML/DDL results expose their affected-row count without + attempting to fetch a cursor that the Microsoft driver did not create. +- **Values and formatting.** UTF-8, system, binary, and default statement encodings + follow the extension's scope rules. As in the Microsoft PHP driver, ordinary binds + derive their SQL defaults from the PHP value and statement encoding instead of + calling `SQLDescribeParam`: UTF-8 text uses `SQL_WVARCHAR`, system text uses + `SQL_VARCHAR`, binary strings use `SQL_VARBINARY`, and scalar/NULL defaults preserve + their corresponding SQL families. This avoids the legacy `TEXT` descriptor that + Driver 17/18 can return for parameters targeting SQL Server temporary tables. PHP + floating-point input values use an aligned `SQL_C_DOUBLE` buffer through execution + and `SQL_FLOAT` when no explicit numeric descriptor exists. Numeric fetch mode + returns native integers/floats where SQLSRV does; datetime fetch mode creates + `DateTime`; decimal leading zeros and money decimal places honor + `SQLSRV_ATTR_FORMAT_DECIMALS` and `SQLSRV_ATTR_DECIMAL_PLACES`. Always Encrypted + metadata and its native parameter-description path remain owned by the installed + Microsoft driver. +- **Identity, quoting, and information.** `lastInsertId()` uses `@@IDENTITY`, or + `sys.sequences` for a supplied sequence name. Text quoting doubles apostrophes + and uses the national prefix for UTF-8/default strings; binary/LOB quoting uses + uppercase hexadecimal. Client/server information arrays expose the same keys as + PDO_SQLSRV, including extension version `5.13.1`. +- **Metadata and classification.** Column names, declared types, and table names use + Unicode descriptor calls. `getColumnMeta()` exposes `sqlsrv:decl_type`, the common + PDO fields, and, when requested, parses Microsoft ODBC sensitivity metadata into + the nested `Data Classification`, label, information-type, and rank arrays. +- **External boundaries.** TLS, Kerberos/GSSAPI, Entra credentials, Always Encrypted + key stores, and server-side classification availability remain capabilities of the + installed Microsoft ODBC driver and external infrastructure, just as they are for + the PHP extension. The bridge forwards supported connection-string controls and + does not emulate credentials or a KDC. + +## PDO_OCI notes + +PDO_OCI is optional because, like PHP's extension, it needs an Oracle client at runtime: + +```bash +cargo run --features pdo-oci -- app.php +``` + +Install Oracle Instant Client for the target and expose its directory through the +platform loader (`LD_LIBRARY_PATH` on Linux or the corresponding macOS loader path). +The bridge uses Oracle's ODPI-C layer, which resolves `libclntsh` dynamically; compiling +elephc and the bridge itself therefore needs neither Oracle headers nor a client install. +Use Oracle's complete Basic or Basic Light Instant Client package rather than copying a +selection of libraries out of a database server's Oracle Home: the client package owns +the complete loader-compatible dependency set. Linux installations also need the +platform `libaio` package, as documented by Oracle. + +- **Version surface.** PHP bundled PDO_OCI through 8.3 and moved it to PECL in PHP 8.4. + The current PECL 1.2.0 surface keeps `PDO::OCI_ATTR_ACTION`, `CLIENT_INFO`, + `CLIENT_IDENTIFIER`, `MODULE`, and `CALL_TIMEOUT`; it does not define `Pdo\Oci`. + elephc follows that split on every compatibility target. +- **DSN and encoding.** `dbname`, `user`, `password`, and `charset` follow PDO_OCI; + constructor credentials override DSN credentials. Compiled PHP strings cross ODPI-C as + UTF-8, so `AL32UTF8` and `UTF8` are accepted explicitly and another client character set + fails at connection setup rather than being ignored. +- **Execution.** Oracle native placeholders, repeated named binds, scroll orientations, + prefetch rows, autocommit, tracked transactions, affected-row counts, ping-based + persistent checkout, and Oracle SQLSTATE/native diagnostics are wired to the client. + Constructor failures preserve PDO_OCI's native code and special SQLSTATE mappings. + Scalar Oracle values and scalar input/output results remain strings like PDO_OCI. + Input `PARAM_LOB` strings/streams use temporary Oracle BLOBs; null LOB output binds and + fetched BLOB/CLOB/NCLOB/BFILE values are exposed as PHP streams. +- **Attributes and metadata.** Session action/module/client fields and millisecond call + timeout are live. `getColumnMeta()` reports PDO_OCI's `oci:decl_type`, `native_type`, + `pdo_type`, `scale`, and nullable/not-null/blob flags. +- **Unsupported official hooks.** PDO_OCI itself has no last-insert-id hook, connection- + status attribute, or driver subclass. `PDO::quote()` uses the driver's single-quote + doubling implementation. + +## PDO_CUBRID notes + +PDO_CUBRID is optional because PHP's official external extension delegates its protocol +and authentication behavior to the CUBRID CCI client: + +```bash +cargo run --features pdo-cubrid -- app.php +``` + +Install the CCI client for the target platform. If the dynamic loader cannot find +`libcascci` under its conventional name, point `CUBRID_CCI_LIBRARY` at the exact shared +library path. The profile is SDK-independent at build time and preserves CCI as the +runtime boundary. + +- **Version surface.** The extension keeps its historical constants directly on `PDO` + and exposes `PDO::cubrid_schema()`; it has no `Pdo\Cubrid` subclass. Because it is + source-built outside php-src, that surface is available on every elephc PHP 8.0–8.6 + compatibility target when the Cargo profile is enabled. +- **DSN and options.** `host`, `port`, `dbname`, `user`, and `password` are translated to + CCI's connection URL. Constructor credentials override DSN credentials. String-keyed + constructor options become CCI URL properties, as in the official driver. +- **Execution.** Native CCI prepare/bind/execute, repeated named placeholders, + positional placeholders, scroll orientations, multiple result sets, affected rows, + textual `lastInsertId()`, autocommit, explicit transactions, and liveness checks are + implemented. `bindParam()`'s CUBRID type-name option, ENUM conversion, array-to-CCI + collection binding, BLOB/CLOB file-or-stream input, and fetched BLOB/CLOB values use + the same native CCI conversion and LOB APIs as the official extension. +- **Attributes and schema.** Isolation level (`1000`), lock timeout (`1001`), maximum + string length (`1002`), query timeout, and autocommit use the corresponding live CCI + calls. `cubrid_schema()` covers the official schema constants 1–20. +- **Quoting and metadata.** `PDO::quote()` calls CCI's connection-aware + `cci_escape_string()` and returns its exact output, including the official driver's + unusual absence of enclosing quotes. `getColumnMeta()` reports the official `type`, + `name`, `table`, `def`, `precision`, `scale`, nullability, auto-increment, key, and + reverse-index fields. + +## TLS / encrypted connections + +PostgreSQL and MySQL connect over TLS with [rustls](https://github.com/rustls/rustls). +DBLIB encryption is negotiated by the installed FreeTDS configuration, just as it is +for php-src; SQLite is in-process and unaffected. + +**PostgreSQL** — ships in the default build (ring provider, no aws-lc-rs). Configure it +with the usual libpq DSN keys: + +```php + "/path/ca.pem", // trust this CA + // Pdo\Mysql::ATTR_SSL_CERT => "/path/client.pem", // mutual TLS (with SSL_KEY) + // Pdo\Mysql::ATTR_SSL_KEY => "/path/client.key", + // Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT => false, // skip verification (insecure) +]); +``` + +- `ATTR_SSL_CA`: a PEM CA bundle to trust (in addition to the bundled webpki roots). +- `ATTR_SSL_CERT` + `ATTR_SSL_KEY`: client certificate + key for mutual TLS. +- `ATTR_SSL_VERIFY_SERVER_CERT`: `false` disables certificate and hostname checking. +- `ATTR_SSL_CAPATH`: a CA directory. Its PEM certificates are combined into the + per-connection bundle rustls accepts, alongside `ATTR_SSL_CA` when both are set. +- `ATTR_SSL_CIPHER` restricts rustls to the named modern suites. `ATTR_SERVER_PUBLIC_KEY` + supplies the trusted RSA key used by non-TLS `caching_sha2_password` authentication. + Unsupported legacy OpenSSL cipher names fail the connection instead of silently + broadening the negotiated suite set. + +Presence of any `ATTR_SSL_*` option enables TLS. A custom minimal build that disables +the default `mysql-tls` feature raises a `PDOException` rather than silently falling back +to plaintext. ## Transactions @@ -211,10 +984,21 @@ try { } ``` +Starting a nested transaction, or committing / rolling back with none active, throws a +`PDOException` regardless of the error mode. `__destruct` rolls back an open transaction +before closing. + +`inTransaction()` (and `beginTransaction()`'s already-active guard) consult the +driver's **live** transaction state where one exists: for SQLite this is +`sqlite3_get_autocommit()`, so a transaction started by a raw `exec("BEGIN")` — bypassing +`beginTransaction()` — is seen too. Neither pure-Rust client this bridge uses exposes a +live transaction-status accessor for PostgreSQL/MySQL, so those fall back to the +`beginTransaction()`/`commit()`/`rollBack()` flag. + ## Errors -The default error mode is `PDO::ERRMODE_EXCEPTION`: a failed `exec()`, `prepare()`, -or connection throws a `PDOException` (which extends `RuntimeException`). +The default error mode is `PDO::ERRMODE_EXCEPTION`: a failed `exec()`, `prepare()`, or +connection throws a `PDOException` (which extends `RuntimeException`). ```php exec("NOT VALID SQL"); } catch (PDOException $e) { echo $e->getMessage(); + echo $e->errorInfo[0]; // the SQLSTATE } ``` -`PDO::errorCode()` returns the driver's native result code as a string and -`PDO::errorInfo()` returns `[code, code, message]`. Note that the first element -is the native driver code, not a real 5-character `SQLSTATE` — the client -libraries used here do not expose `SQLSTATE`s (see Limitations). +`PDO::errorCode()` returns the 5-character `SQLSTATE` for the last operation (`"00000"` +on success) and `PDO::errorInfo()` starts with `[SQLSTATE, driver-specific code, +message]`, with `["00000", null, null]` on success. Every driver surfaces a real +`SQLSTATE`: SQLite through a php-src-matching table, MySQL from the `ERR` packet's +`#`-marked field, and PostgreSQL from the `ErrorResponse` `C` field. DBLIB follows +php-src's extended failure shape by appending the operating-system error code and +severity, then the operating-system message when present. `PDOStatement` tracks its +own error state through the same `errorCode()` / `errorInfo()` pair. The error mode is configurable through `ATTR_ERRMODE`: @@ -242,59 +1031,365 @@ if ($db->exec("BAD SQL") === false) { ``` - `ERRMODE_EXCEPTION` (default) throws a `PDOException`. -- `ERRMODE_SILENT` suppresses it: `exec()`, `query()`, and `prepare()` all return - `false` on error (check with `=== false`). -- `ERRMODE_WARNING` writes the message to `STDERR` and returns the same failure - value as `SILENT`. - -The mode can also be seeded from the constructor's options array: -`new PDO($dsn, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT])`. -Prepared statements inherit the connection's current error mode when they are -created. `getAttribute()` reads attributes back; `ATTR_DRIVER_NAME` reports the -active driver (`"sqlite"`, `"pgsql"`, or `"mysql"`). `ATTR_PERSISTENT` can be set -in the constructor options to use the process-local DSN pool; setting it later -with `setAttribute()` updates the reported attribute but does not reopen an -already-created connection. Persistent connections are local to the running -native process; there is no cross-process pool. +- `ERRMODE_SILENT` suppresses it: `exec()`, `query()`, and `prepare()` all return `false` + on error (check with `=== false`). +- `ERRMODE_WARNING` writes the message to `STDERR` and returns the same failure value as + `SILENT`. + +Every synthetic (non-driver) failure — HY093 bind errors, IM001 unsupported attributes, +HY000 fetch-mode errors, `nextRowset()` — is **errmode-aware** in the same way: it throws +under EXCEPTION, warns under WARNING, and is quiet under SILENT, always returning the +method's own failure value. + +The mode can also be seeded from the constructor's options array: `new PDO($dsn, null, +null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT])`. Prepared statements inherit the +connection's current error mode when they are created. + +### The bridge never aborts your program + +Every `extern "C"` entry point of the native bridge runs its body inside a `catch_unwind` +panic firewall, and takes every handle-table lock through a poison-recovering helper. An +internal panic — an `unwrap` in the bridge, an unexpected panic out of the `postgres` / +`mysql` client crates — therefore degrades into the same well-defined failure sentinel the +entry point already promises for an unknown handle, which the prelude turns into a +catchable `PDOException`. Without the pair, the unwind out of a plain `extern "C"` function +would abort the whole compiled process, and one panic taken under a table lock would poison +that mutex and brick PDO for every later call in the process. + +### `PDOException` shape + +`PDOException` keeps PHP's inherited public constructor signature: +`new PDOException(string $message = "", int $code = 0, ?Throwable $previous = null)`. +Driver failures use a private prelude-only factory to attach structured metadata without +exposing a non-PHP constructor shape: + +- **`$e->errorInfo`** is a real `[SQLSTATE, driver-code, message]` array for a server + error (so `$e->errorInfo[0]` works — which is what frameworks read) and `null` when + there is no structured info, matching PHP. +- **`getCode()`** returns PDO's SQLSTATE string when structured driver information exists; + the driver-specific integer remains available in `errorInfo[1]`. +- **`getPrevious()`** returns the stored previous Throwable. The same value is also exposed + as `$e->previous` because elephc's base Throwable layout has no private previous slot. + +## Under `--web` + +Each prefork worker holds its own connections: N workers means N independent SQLite +handles on the same database file, so concurrent writes contend. For a write-heavy +`--web` app, open the database in WAL mode and set a busy timeout so a contended write +waits instead of failing immediately: + +```php + 5]); +$db->exec("PRAGMA journal_mode=WAL"); +``` + +`ATTR_TIMEOUT` is expressed in seconds (mapped to SQLite's millisecond busy-timeout). +`ATTR_PERSISTENT` connections live in a per-worker pool keyed by DSN, so they persist +across requests handled by the same worker but are never shared across workers or across +a worker respawn. The bridge's connection and result state lives outside the per-request +PHP heap, so it is unaffected by the per-request heap reset the web runtime performs +between requests. ## Supported surface +- **`pdo_drivers(): array`** — the global, procedural spelling of + `PDO::getAvailableDrivers()`. A bare, case-insensitive call is sufficient to + auto-inject the PDO prelude and bridge; `--with-pdo` is not required. - **PDO**: `__construct`, `exec`, `query`, `prepare`, `quote`, `lastInsertId`, - `beginTransaction`, `commit`, `rollBack`, `errorCode`, `errorInfo`, - `getAttribute`, `setAttribute`, `__destruct`. -- **PDOStatement**: `execute`, `bindValue`, `bindParam`, `setFetchMode`, `fetch`, - `fetchAll`, `fetchColumn`, `rowCount`, `columnCount`, `__destruct`; Traversable, - so a statement can be walked with `foreach`. + `beginTransaction`, `commit`, `rollBack`, `inTransaction`, `errorCode`, `errorInfo`, + `getAttribute`, `setAttribute`, `getAvailableDrivers` (static), `connect` (static + factory), `__destruct`. `clone $pdo` throws (PHP forbids it too), and + `serialize($pdo)` throws `Exception: Serialization of 'PDO' is not allowed` — + php-src marks the class `@not-serializable`, and without the guard elephc's + property-walking `serialize()` would emit the raw bridge handle into the blob and + hand back a zombie object on `unserialize()`. +- **PDOStatement**: `execute`, `bindValue`, `bindParam`, `bindColumn`, `setFetchMode`, + `fetch`, `fetchAll`, `fetchColumn`, `fetchObject`, + `closeCursor`, `errorCode`, `errorInfo`, `rowCount`, `columnCount`, `getColumnMeta`, + `getAttribute`, `setAttribute`, `nextRowset`, `debugDumpParams`, `getIterator`, + `__destruct`, plus the public **`readonly`** `$queryString` property (the prepared + SQL — it can never be overwritten; see "readonly `$queryString`" under Divergences for + how the rejection surfaces). `fetch*()` on a statement that has not been + `execute()`d (or after `closeCursor()`) returns `false` rather than stepping the query. + `clone $stmt` and `serialize($stmt)` throw, like `PDO`'s. + `new PDOStatement(...)` constructed directly throws a `PDOException` ("You should not + create a PDOStatement manually"). +- **`debugDumpParams()`** reproduces php-src's full line shapes (`SQL: [n] …`, + `Params: n`, then a `Key:`/`paramno=`/`name=`/`is_param=`/`param_type=` block per + bind), including php-src's reported `param_type` (an `execute($params)` array stamps + every element `PARAM_STR`, whatever the PHP value's type). Emulated MySQL/PostgreSQL + statements also print php-src's `Sent SQL: [n] ...` line using the exact SQL rendered + by the bridge; native and SQLite statements omit it. + Rebinding replaces the visible entry, and a named parameter reports `paramno=-1` + until its first execute-time normalization, as in php-src. +- **Constants**: the selected PHP version's complete PDO set — fetch-mode (base modes plus the OR-able + `FETCH_GROUP` / `FETCH_UNIQUE` / `FETCH_CLASSTYPE` / `FETCH_PROPS_LATE` / … flags), + parameter (including `PARAM_STR_NATL` / `PARAM_STR_CHAR` / `PARAM_INPUT_OUTPUT`), + cursor, case, null-handling, and `ATTR_*` constants, plus `ERR_NONE` (`"00000"`), the + parameter-lifecycle `PARAM_EVT_*` constants (declared so code enumerating the class + surface compiles — they are entirely inert here, since elephc's drivers are native Rust + and expose no `param_hook` seam to PHP), and the **legacy `PDO::SQLITE_*` aliases** + (`PDO::SQLITE_ATTR_OPEN_FLAGS`, `PDO::SQLITE_OPEN_*`, `PDO::SQLITE_DETERMINISTIC`, + `PDO::SQLITE_ATTR_READONLY_STATEMENT`, `PDO::SQLITE_ATTR_EXTENDED_RESULT_CODES`), + which php-src registers on the base class alongside the 8.1+ class-scoped spellings. +- **Driver subclasses**: `Pdo\Sqlite`, `Pdo\Mysql`, and `Pdo\Pgsql` (PHP 8.4+) extend + `PDO` and inherit its full base surface, so `new \Pdo\Sqlite("sqlite::…")` works like + `new \PDO(...)` and the instance is `instanceof \PDO`. A program that names only a + subclass — never the base `PDO` — still injects the prelude, and `PDO::connect($dsn, …)` + returns the matching subclass for the DSN's driver prefix (an unknown prefix throws). + Each subclass declares its PHP 8.4 driver-specific constants. + + **Opening a foreign DSN through a subclass is rejected**, for constructors and the + inherited static factory: `new Pdo\Sqlite("mysql:…")` and + `Pdo\Sqlite::connect("mysql:…")` both fail before connecting. + + Driver methods: `Pdo\Pgsql::escapeIdentifier()`, `getPid()`, `lobCreate()` / + `lobUnlink()` / `lobOpen()`, `copyFromArray()` / `copyFromFile()` / `copyToArray()` / + `copyToFile()`, `getNotify()`, `setNoticeCallback()`; `Pdo\Mysql::getWarningCount()`; + `Pdo\Sqlite::loadExtension()`, `openBlob()`, `createCollation()`, `createFunction()`, + `createAggregate()`, and `setAuthorizer()` on PHP 8.5+. + + PHP 8.4's legacy driver-extension methods are also installed directly on `PDO`: + `sqliteCreateFunction()`, `sqliteCreateAggregate()`, `sqliteCreateCollation()`, + `pgsqlCopyFromArray()`, `pgsqlCopyFromFile()`, `pgsqlCopyToArray()`, + `pgsqlCopyToFile()`, `pgsqlLOBCreate()`, `pgsqlLOBOpen()`, `pgsqlLOBUnlink()`, + `pgsqlGetNotify()`, and `pgsqlGetPid()`. They use the same bridge behavior as the + modern subclass spellings. Connections and prepared statements release their underlying bridge resources automatically through `__destruct`: a `PDO` closes its connection (finalizing any -remaining statements) and a `PDOStatement` finalizes itself when the object is -released — at the end of its scope, when its variable is reassigned or `unset()`, -or at program exit. You do not need to close them explicitly. -- **Fetch modes**: `FETCH_ASSOC`, `FETCH_NUM`, `FETCH_BOTH`, `FETCH_OBJ`, - `FETCH_COLUMN` (a single column as a scalar; the column index is the second - argument to `setFetchMode(PDO::FETCH_COLUMN, $col)`), `FETCH_CLASS`, and - `FETCH_INTO`. -- **Parameters**: positional `?` and named `:name`; `PARAM_INT` / `PARAM_STR` / - `PARAM_NULL` / `PARAM_BOOL` constants. -- **Constants**: the fetch-mode, parameter, `ATTR_ERRMODE`, - `ATTR_DRIVER_NAME`, `ATTR_PERSISTENT`, and `ERRMODE_*` constants used above. +remaining statements) and a `PDOStatement` finalizes itself when the object is released +— at the end of its scope, when its variable is reassigned or `unset()`, or at program +exit. You do not need to close them explicitly. A statement also **roots its owning +`PDO`**, so `return $db->query(...)` from inside a function whose local `$db` then goes +out of scope keeps the connection alive. + +## SQLite user-defined functions and collations + +`Pdo\Sqlite` runs compiled-PHP callables as SQLite callbacks: + +- `createCollation(string $name, callable $comparator): bool` — registers a custom + `COLLATE` ordering; `$comparator($a, $b)` returns `<0` / `0` / `>0`. +- `createFunction(string $function_name, callable $callback, int $num_args = -1, int $flags = 0): bool` + — registers a scalar SQL function invoked once per row; `$flags` may be + `Pdo\Sqlite::DETERMINISTIC`. +- `createAggregate(string $name, callable $step, callable $finalize, int $numArgs = -1): bool` + — registers an aggregate: `$step($context, $rownumber, ...$values)` runs per row and + returns the running accumulator (`null` before the first row), and + `$finalize($context, $rownumber)` returns the group result. + +```php +$db = new \Pdo\Sqlite("sqlite::memory:"); +$db->createFunction("shout", fn($s) => strtoupper($s) . "!"); +$db->createAggregate("joined", + fn($acc, $n, $v) => $n === 0 ? $v : $acc . "," . $v, + fn($acc, $n) => $acc); +echo $db->query("SELECT shout('hi')")->fetchColumn(); // HI! +``` + +Closures, function-name strings, static/instance callable arrays, invokable objects, and +first-class callables are accepted. A callback that throws never crashes or unwinds +across SQLite's engine: the exception is caught at the C boundary and the statement fails +with a `PDOException` (a throwing *collation* comparator is instead treated as "equal", +since SQLite's comparison has no error channel). A UDF/aggregate returning a **non-scalar** +value is reported as a callback error instead of being silently converted to SQL `NULL`. +Callbacks may register/replace another callback and execute nested statements on the same +SQLite connection. The bridge releases its global connection/statement table locks before +SQLite invokes PHP, while SQLite's own serialized connection mutex remains authoritative. + +`Pdo\Pgsql::setNoticeCallback(?callable $callback): void` registers a callback invoked with +the text of each PostgreSQL server `NOTICE` (e.g. from `RAISE NOTICE`): + +```php +$pg = new \Pdo\Pgsql("pgsql:host=localhost;dbname=app"); +$pg->setNoticeCallback(fn($msg) => error_log("PG NOTICE: $msg")); +$pg->exec("DO $$ BEGIN RAISE NOTICE 'migrated'; END $$"); // callback fires with "migrated" +``` + +Passing `null` unregisters delivery. Delivery is **boundary-dispatched**: the driver buffers notices as they arrive and +dispatches them right after each `exec()` / `query()` on the connection, so a `NOTICE` +raised by a prepared-statement `execute()` is delivered on the next `exec()`/`query()`. + +## Divergences from php-src + +These are behavioral differences you can *observe* — not missing features. Read them +before assuming php-src semantics. + +### Native and emulated prepare protocols + +MySQL follows php-src's default and starts with `ATTR_EMULATE_PREPARES = true`. Its +scanner renders placeholders client-side, quotes values according to the connection's +`NO_BACKSLASH_ESCAPES` mode, skips strings/comments/backticks, treats `??` as a literal +question mark and sends the result through the text protocol. Setting +`ATTR_EMULATE_PREPARES` (or `Pdo\Mysql::ATTR_DIRECT_QUERY`) to `false` switches new +statements to server-side prepare. + +PostgreSQL defaults to native extended-query prepares. `ATTR_EMULATE_PREPARES = true` +selects client-side rendering plus the simple-query protocol; +`Pdo\Pgsql::ATTR_DISABLE_PREPARES = true` selects the execute-only simple-query path. +The latter supports multi-command and utility SQL that cannot be represented by one +server-side prepared statement. Generated `$N` marker ranges are tracked separately so +a literal PostgreSQL `$1` already present in source SQL is never mistaken for a PDO bind. + +Both emulated paths reject mixed placeholder styles and missing bindings with HY093, +preserve source SQL evaluation order, and retain the rendered text for +`debugDumpParams()`'s `Sent SQL:` line. The protocol choice is snapshotted on each +`PDOStatement`; changing the connection attribute only affects statements prepared later. + +### Other divergences + +- **`queryString` uses the language's `readonly` enforcement.** Writes are rejected with + a catchable `Error`, including through a `PDOStatement|false` receiver, but the message + is PHP's generic readonly-property text rather than pdo_stmt.c's custom wording. +- **PostgreSQL native error code.** The Rust PostgreSQL client exposes SQLSTATE but has no + libpq `ExecStatusType`; `errorInfo()[1]` therefore uses stable non-zero marker `1` on + failure instead of fabricating a `PGRES_*` enum value. +- **Notice timing.** PostgreSQL notices are buffered on the protocol callback and + dispatched at the PHP boundary that completed the operation: `exec()`, `query()`, or + prepared `execute()`. PHP is never re-entered from the client's protocol thread. +- **UPSTREAM php-src bug, deliberately NOT reproduced.** php-src 8.4's `copyToArray()` / + `copyToFile()` build `COPY … TO STDIN` (`pgsql_driver.c:882,884,973,975`) — an invalid + direction in PostgreSQL's COPY grammar. elephc correctly emits `TO STDOUT`. Do not "fix" + this to match php-src. ## Limitations -- **SQLite, PostgreSQL, and MySQL / MariaDB.** Other PDO drivers (Oracle, SQL - Server, …) are not implemented; the bridge is structured to add more behind the - same prelude. -- **`PDO::quote()`** applies SQLite-style single-quote escaping for every driver; - it is not driver-aware (for MySQL it does not escape backslashes), so prefer - prepared statements (the recommended path for every driver). -- **`errorCode()` / `errorInfo()`** report the driver's *native* error code, not a - real 5-character `SQLSTATE`: SQLite and MySQL expose native integer codes, and - the PostgreSQL client surfaces only a message (reported as a generic code). - `errorInfo()[0]` therefore mirrors the native code rather than a true `SQLSTATE`. -- **`bindParam()`** binds the current value, not a deferred by-reference read. -- **`getAttribute` / `setAttribute`** support `ATTR_ERRMODE`, - `ATTR_DRIVER_NAME`, and `ATTR_PERSISTENT`; other attributes are stored and read - back but have no effect. -- Avoid `new PDOStatement(...)` directly — statements are created by `query()` / - `prepare()`. +### Driver matrix boundary + +- **Compiled drivers.** SQLite, PostgreSQL, and MySQL / MariaDB are in the default + profile; DBLIB, Firebird, ODBC, Informix, IBM, SQLSRV, OCI, and CUBRID are available + through their optional profiles. + The central registry intentionally reports + only drivers present in the selected archive rather than advertising inert names. + +### Validation boundary + +- CI builds the default PDO bridge and every optional driver profile on macOS AArch64, + Linux AArch64, and Linux x86_64. Surface tests select the PHP-version-specific API + independently from the target architecture. +- The live-database workflow exercises PostgreSQL, MySQL/MariaDB, DBLIB, Firebird, ODBC, + SQLSRV, OCI, and CUBRID on Linux, including the libpq GSSAPI/Kerberos profile. SQLite is + exercised directly by the ordinary codegen suite and needs no external service. +- PDO_INFORMIX and PDO_IBM require proprietary Client SDK installations and servers that + the public workflow cannot redistribute. CI builds their profiles and runs unit plus + compiled-surface tests; a fully native live qualification still requires externally + supplied IBM/HCL client libraries, DSNs, and credentials. +- Live services are not replicated on every target architecture. The three-target jobs + prove that every optional bridge builds for the supported matrix; the hermetic live + workflow supplies protocol/runtime acceptance on Linux x86_64. Do not interpret that + split as proof that each proprietary client installation has been exercised on all + three targets. + +### Driver-specific client options + +- MySQL constructor options implement buffered/unbuffered observation, + `ATTR_LOCAL_INFILE` with an optional canonical `ATTR_LOCAL_INFILE_DIRECTORY` sandbox, + compression, ignore-space, multi-statement control, init command, found-rows, and the + supported TLS key/cert/CA/verification settings. A disabled local-infile handler rejects + the server request instead of returning a successful empty upload. +- `Pdo\Mysql::ATTR_SSL_CAPATH` is adapted to rustls by building a deterministic + multi-certificate PEM bundle. The pinned mysql 28 patch adds `ATTR_SSL_CIPHER` and + `ATTR_SERVER_PUBLIC_KEY`, the two controls absent from its public API. No security option + is accepted inertly. +- `Pdo\Pgsql::ATTR_RESULT_MEMORY_SIZE` reports the bytes owned by the native + result and returns `null` with HY000 before statement execution. `ATTR_PREFETCH` is + supported at connection and prepare-option scope. +- PDO_DBLIB's full 1000–1006 attribute range is implemented. Connection timeout is + constructor-only; query timeout is write-only; version attributes are read-only; + boolean value options are readable and writable, matching the driver's php-src hook. +- PDO_FIREBIRD's format, isolation, writable-transaction, autocommit, and + fetch-table-name attributes are implemented, including their PHP-version aliases. +- PDO_IBM's client-info and trusted-context attributes are implemented through + the installed IBM CLI driver, including the PHP 8.4 class and PHP 8.5 alias deprecations. +- PDO_SQLSRV's full 1000–1009 attribute range is implemented at the connection, + prepare-only, or statement scope used upstream; Microsoft-only authentication and + classification descriptor fields cross the native ODBC ABI. +- PDO_OCI's autocommit, prefetch, call-timeout, action, module, client-info, and + client-identifier attributes are implemented through Oracle Instant Client. +- PDO_CUBRID's autocommit, timeout, isolation, lock-timeout, maximum-string-length, + schema, scroll-cursor, rowset, LOB, quoting, and native metadata hooks are implemented + through the official CCI client. +- `ATTR_MAX_COLUMN_LEN`, `ATTR_FETCH_CATALOG_NAMES`, and `ATTR_CURSOR_NAME` are rejected + when the active driver has no corresponding php-src hook/capability. + +### PostgreSQL DSN option handling + +`tokio-postgres`'s connection-string parser hard-fails with `UnknownOption` on any key it +does not know. The bridge forwards the keys it can honor and rejects the others explicitly. +Forwarded: `user`, `password`, `dbname`, `options`, +`application_name`, `sslnegotiation`, `host`, `hostaddr`, `port`, `connect_timeout`, +`tcp_user_timeout`, `keepalives`, `keepalives_idle`, `keepalives_interval`, +`keepalives_retries`, `target_session_attrs`, `channel_binding`, `load_balance_hosts` +— plus `sslmode` / `sslrootcert` / `sslcert` / `sslkey`, which are consumed separately and +applied to the rustls connector. + +`client_encoding` is translated into a validated post-connect session setting. Libpq-only +configuration is resolved before `tokio-postgres`: named `service`/`servicefile` sections, +`.pgpass`/`passfile`, the corresponding `PG*` environment variables, +`fallback_application_name`, `requiressl`, `sslcompression`, `sslsni`, `sslcertmode`, and +TLS 1.2/1.3 bounds all follow libpq-style precedence. A secure passfile is required and a +multi-host passfile is rejected because the native client can carry only one password. + +The default bridge stays pure Rust. For the exact libpq behavior PHP delegates to — GSSAPI +and Kerberos authentication/encryption, encrypted-key `sslpassword`, `require_auth`, and +the `replication` startup parameter — build the PDO archive with: + +```bash +cargo build -p elephc-pdo --features libpq-gss +``` + +With Homebrew's keg-only libpq on Apple Silicon, expose `pg_config` during that build: + +```bash +PATH=/opt/homebrew/opt/libpq/bin:$PATH cargo build -p elephc-pdo --features libpq-gss +``` + +Then export `ELEPHC_PDO_LIBPQ=1` while compiling the PHP program so the final native link +adds `-lpq`. This profile sends the explicit PDO options to `PQconnectdb`; service files, +passfiles, `PG*` environment defaults, and Kerberos/GSS configuration are therefore resolved +by libpq itself. It uses libpq for the complete PostgreSQL connection lifetime, just as +php-src does, and requires a target-compatible libpq with the desired GSS/Kerberos support. +Individual keywords remain version-dependent: for example, a libpq predating +`require_auth` rejects it exactly as the same PHP build would. +The ordinary profile rejects these options explicitly and retains standalone pure-Rust binaries. + +The repository's `scripts/test-pdo-gss.sh` performs the complete integration proof: +it starts an ephemeral MIT Kerberos realm, creates client/server principals and keytabs, +configures PostgreSQL with `hostgssenc`, obtains a client ticket, and connects with both +`gssencmode=require` and `require_auth=gss`. A second isolated process replaces +`KRB5CCNAME` with an empty cache and verifies that libpq fails closed. The PDO live CI +runs this fixture after the ordinary native and libpq suites. + +### Resource and lifetime caveats + +- Persistent checkout is serialized and validates liveness before reuse: MySQL sends + `COM_PING`, PostgreSQL checks the live client state, and a dead handle plus its statements + is evicted before an atomic reconnect. SQLite needs no external-server probe. +- **External-driver rows honor buffering.** MySQL + `ATTR_USE_BUFFERED_QUERY=false` and native PostgreSQL `ATTR_PREFETCH=false` move the + connection into a demand worker and decode one row per `fetch()`, reducing peak memory + to the active row while preserving MySQL's 2014 busy diagnostic and PostgreSQL cursor + invalidation. Buffered modes retain the complete result. PostgreSQL's emulated + simple-query path follows the selected PHP version: PHP 8.0–8.4 retains its historical + buffered behavior, while PHP 8.5+ consumes `simple_query_raw()` one row at a time, matching + php-src's `PQsendQuery()` + `PQsetSingleRowMode()` implementation. +- **LOB streams.** SQLite `openBlob()` keeps only cursor/size state and uses bounded + `sqlite3_blob_read` / `sqlite3_blob_write` slices; its native fixed-size rule rejects + extending writes. PostgreSQL `lobOpen()` likewise keeps only cursor/size state: reads use + bounded `lo_get` slices and writes use bounded `lo_put` patches, preserving binary data, + seeks, sparse extension, and transaction ownership without copying the complete large + object into client memory. +- **`Pdo\Sqlite::loadExtension()`** runs native code from the named library, weakening the + standalone-binary guarantee. An empty name is a `ValueError`. +- **SQLite bridge threadsafety invariant.** The bridge keeps its connection table and its + statement table under **two separate mutexes**, so two overlapping calls can touch the + same `sqlite3*` — which is only defined under a **serialized** SQLite build + (`SQLITE_THREADSAFE=1`). The bundled amalgamation is built that way and + `assert_sqlite_threadsafe()` pins the invariant at the first open (it panics rather than + corrupting memory if a future build ever flips it). Do not link a + `SQLITE_THREADSAFE=0` amalgamation on a threaded target. +- **Database TLS provider.** PostgreSQL and MySQL/MariaDB TLS both ship in the default + build and use rustls with the ring provider. mysql 28's `rustls-tls-ring` feature removes + the former aws-lc-rs/C-toolchain cost. Custom `--no-default-features` builds still reject + a requested TLS connection loudly rather than silently downgrading it. diff --git a/examples/pdo-cubrid/.gitignore b/examples/pdo-cubrid/.gitignore new file mode 100644 index 0000000000..fc9576aeb9 --- /dev/null +++ b/examples/pdo-cubrid/.gitignore @@ -0,0 +1,3 @@ +*.s +*.o +main diff --git a/examples/pdo-cubrid/main.php b/examples/pdo-cubrid/main.php new file mode 100644 index 0000000000..b94d1ba9cc --- /dev/null +++ b/examples/pdo-cubrid/main.php @@ -0,0 +1,20 @@ + PDO::ERRMODE_EXCEPTION, +]); + +$db->exec('DROP TABLE IF EXISTS elephc_animals'); +$db->exec('CREATE TABLE elephc_animals (id INTEGER AUTO_INCREMENT PRIMARY KEY, name VARCHAR(80))'); + +$insert = $db->prepare('INSERT INTO elephc_animals(name) VALUES (:name)'); +$insert->execute(['name' => 'elephant']); + +$row = $db->query('SELECT id, name FROM elephc_animals')->fetch(PDO::FETCH_ASSOC); +echo $row['id'] . ': ' . $row['name'] . PHP_EOL; + +$tables = $db->cubrid_schema(PDO::CUBRID_SCH_TABLE, 'elephc_animals'); +echo 'schema rows: ' . count($tables) . PHP_EOL; + +$db->exec('DROP TABLE elephc_animals'); diff --git a/examples/pdo-dblib/.gitignore b/examples/pdo-dblib/.gitignore new file mode 100644 index 0000000000..fc9576aeb9 --- /dev/null +++ b/examples/pdo-dblib/.gitignore @@ -0,0 +1,3 @@ +*.s +*.o +main diff --git a/examples/pdo-dblib/main.php b/examples/pdo-dblib/main.php new file mode 100644 index 0000000000..95a2013c49 --- /dev/null +++ b/examples/pdo-dblib/main.php @@ -0,0 +1,17 @@ + PDO::ERRMODE_EXCEPTION]); +} catch (Throwable $error) { + echo $dsn . "\n" . $error->getMessage(); + exit(1); +} +$statement = $db->prepare("SELECT :answer AS answer, :name AS name"); +$statement->execute(["answer" => 42, "name" => "elephc"]); +$row = $statement->fetch(PDO::FETCH_ASSOC); + +echo $row["answer"] . ":" . $row["name"]; diff --git a/examples/pdo-firebird/.gitignore b/examples/pdo-firebird/.gitignore new file mode 100644 index 0000000000..fc9576aeb9 --- /dev/null +++ b/examples/pdo-firebird/.gitignore @@ -0,0 +1,3 @@ +*.s +*.o +main diff --git a/examples/pdo-firebird/main.php b/examples/pdo-firebird/main.php new file mode 100644 index 0000000000..538929c0ca --- /dev/null +++ b/examples/pdo-firebird/main.php @@ -0,0 +1,20 @@ + PDO::ERRMODE_EXCEPTION]); +} catch (Throwable $error) { + echo $dsn . "\n" . $error->getMessage(); + exit(1); +} + +$statement = $db->prepare( + "SELECT CAST(:answer AS INTEGER) AS answer, CAST(:name AS VARCHAR(20)) AS name FROM RDB\$DATABASE" +); +$statement->execute(["answer" => 42, "name" => "elephc"]); +$row = $statement->fetch(PDO::FETCH_ASSOC); + +echo $row["ANSWER"] . ":" . trim($row["NAME"]); diff --git a/examples/pdo-ibm/.gitignore b/examples/pdo-ibm/.gitignore new file mode 100644 index 0000000000..fc9576aeb9 --- /dev/null +++ b/examples/pdo-ibm/.gitignore @@ -0,0 +1,3 @@ +*.s +*.o +main diff --git a/examples/pdo-ibm/main.php b/examples/pdo-ibm/main.php new file mode 100644 index 0000000000..baf0c238d4 --- /dev/null +++ b/examples/pdo-ibm/main.php @@ -0,0 +1,17 @@ + PDO::ERRMODE_EXCEPTION]); +} catch (Throwable $error) { + echo $dsn . "\n" . $error->getMessage(); + exit(1); +} + +$statement = $db->query("SELECT CAST(42 AS INTEGER) AS answer, CAST('elephc' AS VARCHAR(20)) AS label FROM SYSIBM.SYSDUMMY1"); +$row = $statement->fetch(PDO::FETCH_ASSOC); + +echo $row["ANSWER"] . ":" . $row["LABEL"]; diff --git a/examples/pdo-informix/.gitignore b/examples/pdo-informix/.gitignore new file mode 100644 index 0000000000..fc9576aeb9 --- /dev/null +++ b/examples/pdo-informix/.gitignore @@ -0,0 +1,3 @@ +*.s +*.o +main diff --git a/examples/pdo-informix/main.php b/examples/pdo-informix/main.php new file mode 100644 index 0000000000..34e9fa0c5d --- /dev/null +++ b/examples/pdo-informix/main.php @@ -0,0 +1,18 @@ + PDO::ERRMODE_EXCEPTION]); +} catch (Throwable $error) { + echo $dsn . "\n" . $error->getMessage(); + exit(1); +} + +$statement = $db->prepare("SELECT CAST(:answer AS INTEGER) AS answer, CAST(:name AS VARCHAR(20)) AS name FROM systables WHERE tabid = 1"); +$statement->execute(["answer" => 42, "name" => "elephc"]); +$row = $statement->fetch(PDO::FETCH_ASSOC); + +echo $row["ANSWER"] . ":" . $row["NAME"]; diff --git a/examples/pdo-mysql/main.php b/examples/pdo-mysql/main.php index 052086e5d5..9dbf6bd992 100644 --- a/examples/pdo-mysql/main.php +++ b/examples/pdo-mysql/main.php @@ -12,13 +12,25 @@ // cargo run -- examples/pdo-mysql/main.php // ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test' \ // ./examples/pdo-mysql/main +// Optional ELEPHC_MY_SERVER_PUBLIC_KEY and ELEPHC_MY_TLS_CIPHER values demonstrate +// mysqlnd-compatible authentication-key and TLS cipher controls. $dsn = (string) getenv("ELEPHC_MY_DSN"); if ($dsn === "") { $dsn = "mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test"; } -$db = new PDO($dsn); +$options = []; +$serverPublicKey = (string) getenv("ELEPHC_MY_SERVER_PUBLIC_KEY"); +if ($serverPublicKey !== "") { + $options[Pdo\Mysql::ATTR_SERVER_PUBLIC_KEY] = $serverPublicKey; +} +$tlsCipher = (string) getenv("ELEPHC_MY_TLS_CIPHER"); +if ($tlsCipher !== "") { + $options[Pdo\Mysql::ATTR_SSL_CIPHER] = $tlsCipher; +} + +$db = new PDO($dsn, null, null, $options); echo "Driver: " . $db->getAttribute(PDO::ATTR_DRIVER_NAME) . "\n\n"; $db->exec("DROP TABLE IF EXISTS contacts"); diff --git a/examples/pdo-oci/.gitignore b/examples/pdo-oci/.gitignore new file mode 100644 index 0000000000..fc9576aeb9 --- /dev/null +++ b/examples/pdo-oci/.gitignore @@ -0,0 +1,3 @@ +*.s +*.o +main diff --git a/examples/pdo-oci/main.php b/examples/pdo-oci/main.php new file mode 100644 index 0000000000..4cc0da3be1 --- /dev/null +++ b/examples/pdo-oci/main.php @@ -0,0 +1,19 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_PREFETCH => 100, +]); +$database->setAttribute(PDO::OCI_ATTR_MODULE, "elephc-example"); + +$statement = $database->prepare("SELECT :message AS MESSAGE FROM DUAL"); +$statement->execute(["message" => "Hello from PDO_OCI"]); +echo $statement->fetchColumn() . "\n"; diff --git a/examples/pdo-odbc/.gitignore b/examples/pdo-odbc/.gitignore new file mode 100644 index 0000000000..fc9576aeb9 --- /dev/null +++ b/examples/pdo-odbc/.gitignore @@ -0,0 +1,3 @@ +*.s +*.o +main diff --git a/examples/pdo-odbc/main.php b/examples/pdo-odbc/main.php new file mode 100644 index 0000000000..6aa21059dc --- /dev/null +++ b/examples/pdo-odbc/main.php @@ -0,0 +1,18 @@ + PDO::ERRMODE_EXCEPTION]); +} catch (Throwable $error) { + echo $dsn . "\n" . $error->getMessage(); + exit(1); +} + +$statement = $db->prepare("SELECT CAST(:answer AS INTEGER) AS answer, CAST(:name AS VARCHAR(20)) AS name"); +$statement->execute(["answer" => 42, "name" => "elephc"]); +$row = $statement->fetch(PDO::FETCH_ASSOC); + +echo $row["answer"] . ":" . $row["name"]; diff --git a/examples/pdo-pgsql/main.php b/examples/pdo-pgsql/main.php index 16487cf300..50618d11f8 100644 --- a/examples/pdo-pgsql/main.php +++ b/examples/pdo-pgsql/main.php @@ -11,13 +11,22 @@ // cargo run -- examples/pdo-pgsql/main.php // ELEPHC_PG_DSN='pgsql:host=localhost;port=55432;dbname=testdb;user=test;password=test' \ // ./examples/pdo-pgsql/main +// +// For libpq-only connection modes such as GSSAPI, `require_auth`, encrypted +// client keys, or replication connections, build the bridge and final program +// with the libpq profile. The DSN itself keeps normal libpq keywords: +// +// cargo build -p elephc-pdo --features libpq-gss +// ELEPHC_PDO_LIBPQ=1 cargo run -- examples/pdo-pgsql/main.php +// Homebrew's keg-only libpq also needs /opt/homebrew/opt/libpq/bin on PATH while +// the bridge is built. $dsn = (string) getenv("ELEPHC_PG_DSN"); if ($dsn === "") { $dsn = "pgsql:host=localhost;port=55432;dbname=testdb;user=test;password=test"; } -$db = new PDO($dsn); +$db = new PDO($dsn, null, null, [PDO::ATTR_PREFETCH => false]); $db->exec("DROP TABLE IF EXISTS contacts"); $db->exec("CREATE TABLE contacts ( diff --git a/examples/pdo-sqlsrv/.gitignore b/examples/pdo-sqlsrv/.gitignore new file mode 100644 index 0000000000..fc9576aeb9 --- /dev/null +++ b/examples/pdo-sqlsrv/.gitignore @@ -0,0 +1,3 @@ +*.s +*.o +main diff --git a/examples/pdo-sqlsrv/main.php b/examples/pdo-sqlsrv/main.php new file mode 100644 index 0000000000..7ab6004669 --- /dev/null +++ b/examples/pdo-sqlsrv/main.php @@ -0,0 +1,21 @@ + PDO::ERRMODE_EXCEPTION]); +} catch (Throwable $error) { + echo $dsn . "\n" . $error->getMessage(); + exit(1); +} + +$statement = $db->prepare( + "SELECT CAST(:answer AS INT) AS answer, CAST(:name AS NVARCHAR(40)) AS label", + [PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE => true] +); +$statement->execute(["answer" => 42, "name" => "éléphant"]); +$row = $statement->fetch(PDO::FETCH_ASSOC); + +echo gettype($row["answer"]) . ":" . $row["answer"] . ":" . $row["label"]; diff --git a/examples/pdo/main.php b/examples/pdo/main.php index 10a06bcb49..e78ce7a439 100644 --- a/examples/pdo/main.php +++ b/examples/pdo/main.php @@ -33,6 +33,24 @@ $row = $one->fetch(PDO::FETCH_ASSOC); echo "Contact #2: " . $row["name"] . " (" . $row["score"] . ")\n\n"; +// bindParam keeps a reference: changing $wanted before the next execute changes the bind. +$wanted = 1; +$reused = $db->prepare("SELECT name FROM contacts WHERE id = ?"); +$reused->bindParam(1, $wanted, PDO::PARAM_INT); +$reused->execute(); +echo "Bound #1: " . $reused->fetchColumn(); +$wanted = 3; +$reused->execute(); +echo ", bound #3: " . $reused->fetchColumn() . "\n"; + +// bindColumn writes through its retained reference after each successful cursor step. +$boundName = ""; +$bound = $db->prepare("SELECT name FROM contacts ORDER BY id LIMIT 1"); +$bound->bindColumn(1, $boundName); +$bound->execute(); +$bound->fetch(PDO::FETCH_BOUND); +echo "Bound column: " . $boundName . "\n\n"; + // FETCH_OBJ creates a real stdClass with dynamic properties. $object = $db->query("SELECT id, name FROM contacts WHERE id = 1")->fetch(PDO::FETCH_OBJ); echo "Object fetch: " . gettype($object) . " #" . $object->id . " " . $object->name . "\n\n"; @@ -42,15 +60,39 @@ class ContactRow { public mixed $name; } -// FETCH_CLASS creates the requested row class and assigns columns directly. -$classRow = $db->query("SELECT id, name FROM contacts WHERE id = 3")->fetch(PDO::FETCH_CLASS, ContactRow::class); +// FETCH_CLASS creates the requested row class and assigns columns directly. The class is carried by +// setFetchMode(), NOT by fetch(): fetch()'s second parameter is the cursor orientation, so passing a +// class name to it is a TypeError in PHP. +$classStmt = $db->query("SELECT id, name FROM contacts WHERE id = 3"); +$classStmt->setFetchMode(PDO::FETCH_CLASS, ContactRow::class); +$classRow = $classStmt->fetch(); echo "Class fetch: " . (($classRow instanceof ContactRow) ? "ContactRow" : "other") . " #" . $classRow->id . "\n"; -// FETCH_INTO fills and returns an existing object. +// FETCH_INTO fills and returns an existing object — again selected through setFetchMode(). $into = new ContactRow(); -$same = $db->query("SELECT id, name FROM contacts WHERE id = 2")->fetch(PDO::FETCH_INTO, $into); +$intoStmt = $db->query("SELECT id, name FROM contacts WHERE id = 2"); +$intoStmt->setFetchMode(PDO::FETCH_INTO, $into); +$same = $intoStmt->fetch(); echo "Into fetch: " . (($same === $into) ? "same" : "different") . " #" . $into->id . "\n\n"; +// FETCH_LAZY returns the statement-owned PDORow view. The same object is refreshed as +// the cursor advances, and columns are available through properties or offsets. +$lazy = $db->query("SELECT id, name FROM contacts ORDER BY id LIMIT 1")->fetch(PDO::FETCH_LAZY); +if ($lazy instanceof PDORow) { + PDORow $typedLazy = $lazy; + echo "Lazy fetch: #" . $typedLazy->id . " " . $typedLazy[1] . "\n"; +} + +// FETCH_FUNC accepts every PHP callable shape, including a function-name string. +function contactLabel($id, $name) { + return "#" . $id . " " . $name; +} +$labels = $db->query("SELECT id, name FROM contacts ORDER BY id")->fetchAll( + PDO::FETCH_FUNC, + "contactLabel" +); +echo "Function fetch: " . implode(", ", $labels) . "\n\n"; + // Binary values preserve embedded NUL bytes. $db->exec("CREATE TABLE blobs (payload BLOB)"); $db->exec("INSERT INTO blobs VALUES (X'410042')"); diff --git a/scripts/ci/run_pdo_codegen_shard.sh b/scripts/ci/run_pdo_codegen_shard.sh new file mode 100755 index 0000000000..e1a4e6a869 --- /dev/null +++ b/scripts/ci/run_pdo_codegen_shard.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Run one deterministic PDO shard inside a single libtest process. + +set -euo pipefail + +# Several PDO fixtures lower the full generated prelude recursively. Rust's default 2 MiB +# libtest worker stack is too small when many tests share one process, especially on AArch64. +export RUST_MIN_STACK=${RUST_MIN_STACK:-33554432} + +if [[ $# -ne 3 ]]; then + echo "usage: run_pdo_codegen_shard.sh " >&2 + exit 2 +fi + +test_binary=$1 +shard=$2 +shard_count=$3 + +if [[ ! -f $test_binary || ! $shard =~ ^[0-9]+$ || ! $shard_count =~ ^[0-9]+$ \ + || $shard -lt 1 || $shard -gt $shard_count ]]; then + echo "invalid test binary or shard selection" >&2 + exit 2 +fi + +selected=() +skipped=() +while IFS= read -r line; do + [[ $line == codegen::pdo*:" test" ]] || continue + test_name=${line%: test} + checksum_line=$(printf '%s' "$test_name" | cksum) + checksum=${checksum_line%% *} + test_shard=$((checksum % shard_count + 1)) + if [[ $test_shard -eq $shard ]]; then + selected+=("$test_name") + else + skipped+=(--skip "$test_name") + fi +done < <("$test_binary" --list) + +if [[ ${#selected[@]} -eq 0 ]]; then + echo "PDO shard $shard/$shard_count selected no tests" >&2 + exit 2 +fi + +echo "Running ${#selected[@]} PDO tests in shard $shard/$shard_count" +exec "$test_binary" codegen::pdo "${skipped[@]}" --test-threads 4 diff --git a/scripts/docs/builtin_registry.json b/scripts/docs/builtin_registry.json index d219145f88..6314ce5d41 100644 --- a/scripts/docs/builtin_registry.json +++ b/scripts/docs/builtin_registry.json @@ -1,4 +1,159 @@ [ + { + "area": "Pointer", + "canonical_name": "__elephc_callable_ptr", + "description": "Reinterprets a closure / first-class callable as its raw descriptor pointer.", + "eval": { + "kind": "none", + "supported": false + }, + "eval_only": false, + "in_catalog": false, + "is_extension": false, + "is_internal": true, + "lowering": { + "checker_file": null, + "checker_line": null, + "codegen_file": "src/builtins/semantics.rs", + "codegen_function": "lower_registry_call", + "codegen_line": 448, + "notes": [ + "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", + "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." + ], + "runtime_helpers": [], + "sig_arm": null, + "sig_file": "src/builtins/pointers/elephc_callable_ptr.rs", + "sig_line": null + }, + "name": "__elephc_callable_ptr", + "semantics": { + "argument_lowering": "standard", + "callable": { + "kind": "static_only", + "reason": "internal compiler primitive" + }, + "effects": { + "kind": "static", + "names": [] + }, + "lowering": { + "kind": "eir" + }, + "ownership": { + "argument_indexes": [], + "kind": "non_heap" + }, + "requirements": { + "kind": "static", + "values": [] + }, + "result_type": "checked", + "runtime_functions": [], + "target_strategy": "eir_primitive", + "target_support": [ + "macos-aarch64", + "linux-aarch64", + "linux-x86_64" + ], + "validation": { + "kind": "checker_hook", + "lazy": false + } + }, + "sig": { + "params": [ + { + "by_ref": false, + "default": null, + "name": "value", + "optional": false, + "type": "mixed" + } + ], + "return_type": "mixed", + "variadic": null + }, + "slug": "__elephc_callable_ptr", + "sub_area": "Pointer" + }, + { + "area": "Misc", + "canonical_name": "__elephc_class_has_constructor", + "description": "Reports whether a dynamically named AOT class has a constructor.", + "eval": { + "kind": "none", + "supported": false + }, + "eval_only": false, + "in_catalog": false, + "is_extension": false, + "is_internal": true, + "lowering": { + "checker_file": null, + "checker_line": null, + "codegen_file": "src/builtins/semantics.rs", + "codegen_function": "lower_registry_call", + "codegen_line": 448, + "notes": [ + "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", + "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." + ], + "runtime_helpers": [], + "sig_arm": null, + "sig_file": "src/builtins/system/__elephc_class_has_constructor.rs", + "sig_line": null + }, + "name": "__elephc_class_has_constructor", + "semantics": { + "argument_lowering": "standard", + "callable": { + "kind": "static_only", + "reason": "internal compiler primitive" + }, + "effects": { + "kind": "static", + "names": [] + }, + "lowering": { + "kind": "eir" + }, + "ownership": { + "argument_indexes": [], + "kind": "non_heap" + }, + "requirements": { + "kind": "static", + "values": [] + }, + "result_type": "declared", + "runtime_functions": [], + "target_strategy": "eir_primitive", + "target_support": [ + "macos-aarch64", + "linux-aarch64", + "linux-x86_64" + ], + "validation": { + "kind": "signature" + } + }, + "sig": { + "params": [ + { + "by_ref": false, + "default": null, + "name": "class", + "optional": false, + "type": "string" + } + ], + "return_type": "bool", + "variadic": null + }, + "slug": "__elephc_class_has_constructor", + "sub_area": "System" + }, { "area": "Misc", "canonical_name": "__elephc_gmmktime_raw", @@ -16,7 +171,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Internal helper used by the gmmktime() builtin.", "Bypasses timezone handling and calls the runtime gmmktime helper directly.", @@ -151,7 +306,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_hash_ctx_copy` through `BuiltinLoweringContext`.", @@ -254,7 +409,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_hash_ctx_final` through `BuiltinLoweringContext`.", @@ -364,7 +519,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_hash_ctx_init` through `BuiltinLoweringContext`.", @@ -467,7 +622,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_hash_ctx_update` through `BuiltinLoweringContext`.", @@ -562,8 +717,8 @@ }, { "area": "Misc", - "canonical_name": "__elephc_mktime_raw", - "description": "Internal raw mktime alias used by the synthetic DateTime body.", + "canonical_name": "__elephc_initialize_pdo_statement", + "description": "Initializes a dynamically allocated PDOStatement subclass.", "eval": { "kind": "none", "supported": false @@ -577,25 +732,22 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ - "Internal helper used by the mktime() builtin.", - "Bypasses timezone handling and calls the runtime mktime helper directly.", - "Uses the `runtime_call` strategy from the single-source builtin descriptor.", - "Emits the typed EIR target `runtime.__elephc_mktime_raw` through `BuiltinLoweringContext`.", - "The backend resolves that typed target through `src/codegen/lower_inst/runtime_calls.rs`; PHP builtin names do not participate in dispatch." + "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", + "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." ], "runtime_helpers": [], "sig_arm": null, - "sig_file": "src/builtins/system/__elephc_mktime_raw.rs", + "sig_file": "src/builtins/system/__elephc_initialize_pdo_statement.rs", "sig_line": null }, - "name": "__elephc_mktime_raw", + "name": "__elephc_initialize_pdo_statement", "semantics": { "argument_lowering": "standard", "callable": { "kind": "static_only", - "reason": "typed backend operation has no runtime-selected wrapper contract" + "reason": "internal compiler primitive" }, "effects": { "kind": "static", @@ -605,6 +757,7 @@ "reads_heap", "writes_heap", "reads_global", + "writes_global", "reads_fs", "writes_fs", "reads_process", @@ -619,22 +772,19 @@ ] }, "lowering": { - "kind": "runtime_call", - "target": "__elephc_mktime_raw" + "kind": "eir" }, "ownership": { "argument_indexes": [], - "kind": "may_alias_arguments" + "kind": "non_heap" }, "requirements": { "kind": "static", "values": [] }, "result_type": "declared", - "runtime_functions": [ - "__elephc_mktime_raw" - ], - "target_strategy": "runtime_call", + "runtime_functions": [], + "target_strategy": "eir_primitive", "target_support": [ "macos-aarch64", "linux-aarch64", @@ -649,56 +799,158 @@ { "by_ref": false, "default": null, - "name": "hour", + "name": "statement", "optional": false, - "type": "int" + "type": "mixed" }, { "by_ref": false, "default": null, - "name": "minute", + "name": "handle", "optional": false, "type": "int" }, { "by_ref": false, "default": null, - "name": "second", + "name": "connection", "optional": false, "type": "int" }, { "by_ref": false, "default": null, - "name": "month", + "name": "errorMode", "optional": false, "type": "int" }, { "by_ref": false, "default": null, - "name": "day", + "name": "query", "optional": false, - "type": "int" + "type": "string" + } + ], + "return_type": "void", + "variadic": null + }, + "slug": "__elephc_initialize_pdo_statement", + "sub_area": "System" + }, + { + "area": "Misc", + "canonical_name": "__elephc_invoke_pdo_statement_constructor", + "description": "Invokes a PDO statement subclass constructor after native initialization.", + "eval": { + "kind": "none", + "supported": false + }, + "eval_only": false, + "in_catalog": false, + "is_extension": false, + "is_internal": true, + "lowering": { + "checker_file": null, + "checker_line": null, + "codegen_file": "src/builtins/semantics.rs", + "codegen_function": "lower_registry_call", + "codegen_line": 448, + "notes": [ + "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", + "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." + ], + "runtime_helpers": [], + "sig_arm": null, + "sig_file": "src/builtins/system/__elephc_invoke_pdo_statement_constructor.rs", + "sig_line": null + }, + "name": "__elephc_invoke_pdo_statement_constructor", + "semantics": { + "argument_lowering": "standard", + "callable": { + "kind": "static_only", + "reason": "internal compiler primitive" + }, + "effects": { + "kind": "static", + "names": [ + "reads_local", + "writes_local", + "reads_heap", + "writes_heap", + "reads_global", + "writes_global", + "reads_fs", + "writes_fs", + "reads_process", + "writes_process", + "output", + "alloc_heap", + "alloc_concat", + "may_throw", + "may_fatal", + "may_warn", + "may_deopt" + ] + }, + "lowering": { + "kind": "eir" + }, + "ownership": { + "argument_indexes": [], + "kind": "non_heap" + }, + "requirements": { + "kind": "static", + "values": [] + }, + "result_type": "declared", + "runtime_functions": [], + "target_strategy": "eir_primitive", + "target_support": [ + "macos-aarch64", + "linux-aarch64", + "linux-x86_64" + ], + "validation": { + "kind": "signature" + } + }, + "sig": { + "params": [ + { + "by_ref": false, + "default": null, + "name": "class", + "optional": false, + "type": "string" }, { "by_ref": false, "default": null, - "name": "year", + "name": "statement", "optional": false, - "type": "int" + "type": "mixed" + }, + { + "by_ref": false, + "default": null, + "name": "arguments", + "optional": false, + "type": "mixed" } ], - "return_type": "int", + "return_type": "void", "variadic": null }, - "slug": "__elephc_mktime_raw", + "slug": "__elephc_invoke_pdo_statement_constructor", "sub_area": "System" }, { - "area": "IO", - "canonical_name": "__elephc_phar_bzip2_archive", - "description": "Compresses a PHAR archive using bzip2.", + "area": "Misc", + "canonical_name": "__elephc_mktime_raw", + "description": "Internal raw mktime alias used by the synthetic DateTime body.", "eval": { "kind": "none", "supported": false @@ -712,18 +964,548 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ + "Internal helper used by the mktime() builtin.", + "Bypasses timezone handling and calls the runtime mktime helper directly.", "Uses the `runtime_call` strategy from the single-source builtin descriptor.", - "Emits the typed EIR target `runtime.__elephc_phar_bzip2_archive` through `BuiltinLoweringContext`.", + "Emits the typed EIR target `runtime.__elephc_mktime_raw` through `BuiltinLoweringContext`.", "The backend resolves that typed target through `src/codegen/lower_inst/runtime_calls.rs`; PHP builtin names do not participate in dispatch." ], "runtime_helpers": [], "sig_arm": null, - "sig_file": "src/builtins/io/__elephc_phar_bzip2_archive.rs", + "sig_file": "src/builtins/system/__elephc_mktime_raw.rs", "sig_line": null }, - "name": "__elephc_phar_bzip2_archive", + "name": "__elephc_mktime_raw", + "semantics": { + "argument_lowering": "standard", + "callable": { + "kind": "static_only", + "reason": "typed backend operation has no runtime-selected wrapper contract" + }, + "effects": { + "kind": "static", + "names": [ + "reads_local", + "writes_local", + "reads_heap", + "writes_heap", + "reads_global", + "reads_fs", + "writes_fs", + "reads_process", + "writes_process", + "output", + "alloc_heap", + "alloc_concat", + "may_throw", + "may_fatal", + "may_warn", + "may_deopt" + ] + }, + "lowering": { + "kind": "runtime_call", + "target": "__elephc_mktime_raw" + }, + "ownership": { + "argument_indexes": [], + "kind": "may_alias_arguments" + }, + "requirements": { + "kind": "static", + "values": [] + }, + "result_type": "declared", + "runtime_functions": [ + "__elephc_mktime_raw" + ], + "target_strategy": "runtime_call", + "target_support": [ + "macos-aarch64", + "linux-aarch64", + "linux-x86_64" + ], + "validation": { + "kind": "signature" + } + }, + "sig": { + "params": [ + { + "by_ref": false, + "default": null, + "name": "hour", + "optional": false, + "type": "int" + }, + { + "by_ref": false, + "default": null, + "name": "minute", + "optional": false, + "type": "int" + }, + { + "by_ref": false, + "default": null, + "name": "second", + "optional": false, + "type": "int" + }, + { + "by_ref": false, + "default": null, + "name": "month", + "optional": false, + "type": "int" + }, + { + "by_ref": false, + "default": null, + "name": "day", + "optional": false, + "type": "int" + }, + { + "by_ref": false, + "default": null, + "name": "year", + "optional": false, + "type": "int" + } + ], + "return_type": "int", + "variadic": null + }, + "slug": "__elephc_mktime_raw", + "sub_area": "System" + }, + { + "area": "Misc", + "canonical_name": "__elephc_new_without_constructor", + "description": "Allocates a dynamically named object without invoking its constructor.", + "eval": { + "kind": "none", + "supported": false + }, + "eval_only": false, + "in_catalog": false, + "is_extension": false, + "is_internal": true, + "lowering": { + "checker_file": null, + "checker_line": null, + "codegen_file": "src/builtins/semantics.rs", + "codegen_function": "lower_registry_call", + "codegen_line": 448, + "notes": [ + "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", + "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." + ], + "runtime_helpers": [], + "sig_arm": null, + "sig_file": "src/builtins/system/__elephc_new_without_constructor.rs", + "sig_line": null + }, + "name": "__elephc_new_without_constructor", + "semantics": { + "argument_lowering": "standard", + "callable": { + "kind": "static_only", + "reason": "internal compiler primitive" + }, + "effects": { + "kind": "static", + "names": [ + "reads_heap", + "alloc_heap", + "may_deopt" + ] + }, + "lowering": { + "kind": "eir" + }, + "ownership": { + "argument_indexes": [], + "kind": "fresh" + }, + "requirements": { + "kind": "static", + "values": [] + }, + "result_type": "declared", + "runtime_functions": [], + "target_strategy": "eir_primitive", + "target_support": [ + "macos-aarch64", + "linux-aarch64", + "linux-x86_64" + ], + "validation": { + "kind": "signature" + } + }, + "sig": { + "params": [ + { + "by_ref": false, + "default": null, + "name": "class", + "optional": false, + "type": "string" + } + ], + "return_type": "mixed", + "variadic": null + }, + "slug": "__elephc_new_without_constructor", + "sub_area": "System" + }, + { + "area": "Pointer", + "canonical_name": "__elephc_normalize_callable", + "description": "Normalizes a PHP callable into an owned runtime descriptor.", + "eval": { + "kind": "none", + "supported": false + }, + "eval_only": false, + "in_catalog": false, + "is_extension": false, + "is_internal": true, + "lowering": { + "checker_file": null, + "checker_line": null, + "codegen_file": "src/builtins/semantics.rs", + "codegen_function": "lower_registry_call", + "codegen_line": 448, + "notes": [ + "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", + "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." + ], + "runtime_helpers": [], + "sig_arm": null, + "sig_file": "src/builtins/pointers/elephc_normalize_callable.rs", + "sig_line": null + }, + "name": "__elephc_normalize_callable", + "semantics": { + "argument_lowering": "standard", + "callable": { + "kind": "static_only", + "reason": "internal compiler primitive" + }, + "effects": { + "kind": "static", + "names": [ + "reads_heap", + "alloc_heap", + "refcount_op" + ] + }, + "lowering": { + "kind": "eir" + }, + "ownership": { + "argument_indexes": [], + "kind": "fresh" + }, + "requirements": { + "kind": "static", + "values": [] + }, + "result_type": "checked", + "runtime_functions": [], + "target_strategy": "eir_primitive", + "target_support": [ + "macos-aarch64", + "linux-aarch64", + "linux-x86_64" + ], + "validation": { + "kind": "checker_hook", + "lazy": false + } + }, + "sig": { + "params": [ + { + "by_ref": false, + "default": null, + "name": "value", + "optional": false, + "type": "mixed" + } + ], + "return_type": "mixed", + "variadic": null + }, + "slug": "__elephc_normalize_callable", + "sub_area": "Pointer" + }, + { + "area": "Pointer", + "canonical_name": "__elephc_pdo_adapter_addr", + "description": "Returns the address of the shared __rt_pdo_* callback adapter for a kind.", + "eval": { + "kind": "none", + "supported": false + }, + "eval_only": false, + "in_catalog": false, + "is_extension": false, + "is_internal": true, + "lowering": { + "checker_file": null, + "checker_line": null, + "codegen_file": "src/builtins/semantics.rs", + "codegen_function": "lower_registry_call", + "codegen_line": 448, + "notes": [ + "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", + "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." + ], + "runtime_helpers": [], + "sig_arm": null, + "sig_file": "src/builtins/pointers/elephc_pdo_adapter_addr.rs", + "sig_line": null + }, + "name": "__elephc_pdo_adapter_addr", + "semantics": { + "argument_lowering": "standard", + "callable": { + "kind": "static_only", + "reason": "internal compiler primitive" + }, + "effects": { + "kind": "static", + "names": [] + }, + "lowering": { + "kind": "eir" + }, + "ownership": { + "argument_indexes": [], + "kind": "non_heap" + }, + "requirements": { + "kind": "static", + "values": [] + }, + "result_type": "checked", + "runtime_functions": [], + "target_strategy": "eir_primitive", + "target_support": [ + "macos-aarch64", + "linux-aarch64", + "linux-x86_64" + ], + "validation": { + "kind": "checker_hook", + "lazy": false + } + }, + "sig": { + "params": [ + { + "by_ref": false, + "default": null, + "name": "kind", + "optional": false, + "type": "int" + } + ], + "return_type": "mixed", + "variadic": null + }, + "slug": "__elephc_pdo_adapter_addr", + "sub_area": "Pointer" + }, + { + "area": "Misc", + "canonical_name": "__elephc_pdo_called_class_status", + "description": "Classifies PDO::connect's late-static called class by driver hierarchy.", + "eval": { + "kind": "none", + "supported": false + }, + "eval_only": false, + "in_catalog": false, + "is_extension": false, + "is_internal": true, + "lowering": { + "checker_file": null, + "checker_line": null, + "codegen_file": "src/builtins/semantics.rs", + "codegen_function": "lower_registry_call", + "codegen_line": 448, + "notes": [ + "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", + "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." + ], + "runtime_helpers": [], + "sig_arm": null, + "sig_file": "src/builtins/system/__elephc_pdo_called_class_status.rs", + "sig_line": null + }, + "name": "__elephc_pdo_called_class_status", + "semantics": { + "argument_lowering": "standard", + "callable": { + "kind": "static_only", + "reason": "internal compiler primitive" + }, + "effects": { + "kind": "static", + "names": [] + }, + "lowering": { + "kind": "eir" + }, + "ownership": { + "argument_indexes": [], + "kind": "non_heap" + }, + "requirements": { + "kind": "static", + "values": [] + }, + "result_type": "declared", + "runtime_functions": [], + "target_strategy": "eir_primitive", + "target_support": [ + "macos-aarch64", + "linux-aarch64", + "linux-x86_64" + ], + "validation": { + "kind": "signature" + } + }, + "sig": { + "params": [ + { + "by_ref": false, + "default": null, + "name": "class", + "optional": false, + "type": "string" + } + ], + "return_type": "int", + "variadic": null + }, + "slug": "__elephc_pdo_called_class_status", + "sub_area": "System" + }, + { + "area": "Misc", + "canonical_name": "__elephc_pdo_statement_class_status", + "description": "Classifies a dynamically named class for PDO statement construction.", + "eval": { + "kind": "none", + "supported": false + }, + "eval_only": false, + "in_catalog": false, + "is_extension": false, + "is_internal": true, + "lowering": { + "checker_file": null, + "checker_line": null, + "codegen_file": "src/builtins/semantics.rs", + "codegen_function": "lower_registry_call", + "codegen_line": 448, + "notes": [ + "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", + "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." + ], + "runtime_helpers": [], + "sig_arm": null, + "sig_file": "src/builtins/system/__elephc_pdo_statement_class_status.rs", + "sig_line": null + }, + "name": "__elephc_pdo_statement_class_status", + "semantics": { + "argument_lowering": "standard", + "callable": { + "kind": "static_only", + "reason": "internal compiler primitive" + }, + "effects": { + "kind": "static", + "names": [] + }, + "lowering": { + "kind": "eir" + }, + "ownership": { + "argument_indexes": [], + "kind": "non_heap" + }, + "requirements": { + "kind": "static", + "values": [] + }, + "result_type": "declared", + "runtime_functions": [], + "target_strategy": "eir_primitive", + "target_support": [ + "macos-aarch64", + "linux-aarch64", + "linux-x86_64" + ], + "validation": { + "kind": "signature" + } + }, + "sig": { + "params": [ + { + "by_ref": false, + "default": null, + "name": "class", + "optional": false, + "type": "string" + } + ], + "return_type": "int", + "variadic": null + }, + "slug": "__elephc_pdo_statement_class_status", + "sub_area": "System" + }, + { + "area": "IO", + "canonical_name": "__elephc_phar_bzip2_archive", + "description": "Compresses a PHAR archive using bzip2.", + "eval": { + "kind": "none", + "supported": false + }, + "eval_only": false, + "in_catalog": false, + "is_extension": false, + "is_internal": true, + "lowering": { + "checker_file": null, + "checker_line": null, + "codegen_file": "src/builtins/semantics.rs", + "codegen_function": "lower_registry_call", + "codegen_line": 448, + "notes": [ + "Uses the `runtime_call` strategy from the single-source builtin descriptor.", + "Emits the typed EIR target `runtime.__elephc_phar_bzip2_archive` through `BuiltinLoweringContext`.", + "The backend resolves that typed target through `src/codegen/lower_inst/runtime_calls.rs`; PHP builtin names do not participate in dispatch." + ], + "runtime_helpers": [], + "sig_arm": null, + "sig_file": "src/builtins/io/__elephc_phar_bzip2_archive.rs", + "sig_line": null + }, + "name": "__elephc_phar_bzip2_archive", "semantics": { "argument_lowering": "standard", "callable": { @@ -815,7 +1597,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_decompress_archive` through `BuiltinLoweringContext`.", @@ -918,7 +1700,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_get_file_metadata` through `BuiltinLoweringContext`.", @@ -1021,7 +1803,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_get_metadata` through `BuiltinLoweringContext`.", @@ -1124,7 +1906,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_get_signature_hash` through `BuiltinLoweringContext`.", @@ -1227,7 +2009,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_get_signature_type` through `BuiltinLoweringContext`.", @@ -1330,7 +2112,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_get_stub` through `BuiltinLoweringContext`.", @@ -1433,7 +2215,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_gzip_archive` through `BuiltinLoweringContext`.", @@ -1536,7 +2318,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Internal helper used by the built-in Phar / PharData support to enumerate archive entries.", "Calls the native PHAR listing bridge and returns the entries as an array.", @@ -1642,7 +2424,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Internal helper used by the built-in Phar / PharData support to change archive compression.", "Calls the native PHAR compression-control bridge and returns whether the update succeeded.", @@ -1754,7 +2536,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_set_file_metadata` through `BuiltinLoweringContext`.", @@ -1864,7 +2646,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_set_metadata` through `BuiltinLoweringContext`.", @@ -1974,7 +2756,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_set_stub` through `BuiltinLoweringContext`.", @@ -2084,7 +2866,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_set_zip_password` through `BuiltinLoweringContext`.", @@ -2187,7 +2969,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_sign_hash` through `BuiltinLoweringContext`.", @@ -2297,7 +3079,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_phar_sign_openssl` through `BuiltinLoweringContext`.", @@ -2407,7 +3189,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_ptr_is_null` through `BuiltinLoweringContext`.", @@ -2506,7 +3288,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_ptr_read_string` through `BuiltinLoweringContext`.", @@ -2612,7 +3394,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.__elephc_ptr_write_string` through `BuiltinLoweringContext`.", @@ -2718,7 +3500,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Internal helper used by the strtotime() builtin.", "Provides a raw timestamp parsing path for the runtime strtotime helper.", @@ -2841,7 +3623,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.abs` through `BuiltinLoweringContext`.", @@ -2939,7 +3721,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.acos` through `BuiltinLoweringContext`.", @@ -3036,7 +3818,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.add_slashes` through `BuiltinLoweringContext`.", @@ -3114,7 +3896,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_all` through `BuiltinLoweringContext`.", @@ -3220,7 +4002,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_any` through `BuiltinLoweringContext`.", @@ -3348,7 +4130,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_chunk` through `BuiltinLoweringContext`.", @@ -3459,7 +4241,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_column` through `BuiltinLoweringContext`.", @@ -3570,7 +4352,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_combine` through `BuiltinLoweringContext`.", @@ -3675,7 +4457,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_diff` through `BuiltinLoweringContext`.", @@ -3757,7 +4539,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_diff_assoc` through `BuiltinLoweringContext`.", @@ -3855,7 +4637,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_diff_key` through `BuiltinLoweringContext`.", @@ -3965,7 +4747,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_fill` through `BuiltinLoweringContext`.", @@ -4083,7 +4865,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_fill_keys` through `BuiltinLoweringContext`.", @@ -4200,7 +4982,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_filter` through `BuiltinLoweringContext`.", @@ -4313,7 +5095,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_find` through `BuiltinLoweringContext`.", @@ -4435,7 +5217,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_flip` through `BuiltinLoweringContext`.", @@ -4533,7 +5315,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_intersect` through `BuiltinLoweringContext`.", @@ -4615,7 +5397,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_intersect_assoc` through `BuiltinLoweringContext`.", @@ -4713,7 +5495,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_intersect_key` through `BuiltinLoweringContext`.", @@ -4795,7 +5577,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_is_list` through `BuiltinLoweringContext`.", @@ -4899,7 +5681,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_key_exists` through `BuiltinLoweringContext`.", @@ -4988,7 +5770,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_key_first` through `BuiltinLoweringContext`.", @@ -5070,7 +5852,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_key_last` through `BuiltinLoweringContext`.", @@ -5168,7 +5950,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_keys` through `BuiltinLoweringContext`.", @@ -5272,7 +6054,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_map` through `BuiltinLoweringContext`.", @@ -5387,7 +6169,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_merge` through `BuiltinLoweringContext`.", @@ -5461,7 +6243,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_merge_recursive` through `BuiltinLoweringContext`.", @@ -5535,7 +6317,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_multisort` through `BuiltinLoweringContext`.", @@ -5669,7 +6451,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_pad` through `BuiltinLoweringContext`.", @@ -5780,7 +6562,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_pop` through `BuiltinLoweringContext`.", @@ -5895,7 +6677,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_product` through `BuiltinLoweringContext`.", @@ -5992,7 +6774,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_push` through `BuiltinLoweringContext`.", @@ -6107,7 +6889,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_rand` through `BuiltinLoweringContext`.", @@ -6234,7 +7016,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_reduce` through `BuiltinLoweringContext`.", @@ -6347,7 +7129,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_replace` through `BuiltinLoweringContext`.", @@ -6436,7 +7218,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_replace_recursive` through `BuiltinLoweringContext`.", @@ -6547,7 +7329,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_reverse` through `BuiltinLoweringContext`.", @@ -6657,7 +7439,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_search` through `BuiltinLoweringContext`.", @@ -6768,7 +7550,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_shift` through `BuiltinLoweringContext`.", @@ -6895,7 +7677,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_slice` through `BuiltinLoweringContext`.", @@ -7024,7 +7806,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_splice` through `BuiltinLoweringContext`.", @@ -7153,7 +7935,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_sum` through `BuiltinLoweringContext`.", @@ -7235,7 +8017,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_udiff` through `BuiltinLoweringContext`.", @@ -7348,7 +8130,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_uintersect` through `BuiltinLoweringContext`.", @@ -7477,7 +8259,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_unique` through `BuiltinLoweringContext`.", @@ -7574,7 +8356,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_unshift` through `BuiltinLoweringContext`.", @@ -7689,7 +8471,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_values` through `BuiltinLoweringContext`.", @@ -7792,7 +8574,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_walk` through `BuiltinLoweringContext`.", @@ -7898,7 +8680,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.array_walk_recursive` through `BuiltinLoweringContext`.", @@ -8019,7 +8801,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.arsort` through `BuiltinLoweringContext`.", @@ -8134,7 +8916,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.asin` through `BuiltinLoweringContext`.", @@ -8230,7 +9012,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.asort` through `BuiltinLoweringContext`.", @@ -8345,7 +9127,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.atan` through `BuiltinLoweringContext`.", @@ -8448,7 +9230,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.atan2` through `BuiltinLoweringContext`.", @@ -8552,7 +9334,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.base64_decode` through `BuiltinLoweringContext`.", @@ -8646,7 +9428,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.base64_encode` through `BuiltinLoweringContext`.", @@ -8746,7 +9528,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.basename` through `BuiltinLoweringContext`.", @@ -8867,7 +9649,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.bin_to_hex` through `BuiltinLoweringContext`.", @@ -8961,7 +9743,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -9052,7 +9834,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.buffer_free` through `BuiltinLoweringContext`.", @@ -9167,7 +9949,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.buffer_len` through `BuiltinLoweringContext`.", @@ -9326,7 +10108,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.call_user_func` through `BuiltinLoweringContext`.", @@ -9447,7 +10229,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.call_user_func_array` through `BuiltinLoweringContext`.", @@ -9569,7 +10351,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ceil` through `BuiltinLoweringContext`.", @@ -9666,7 +10448,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.chdir` through `BuiltinLoweringContext`.", @@ -9792,7 +10574,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.checkdate` through `BuiltinLoweringContext`.", @@ -9926,7 +10708,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.chgrp` through `BuiltinLoweringContext`.", @@ -10054,7 +10836,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.chmod` through `BuiltinLoweringContext`.", @@ -10182,7 +10964,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.chop` through `BuiltinLoweringContext`.", @@ -10292,7 +11074,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.chown` through `BuiltinLoweringContext`.", @@ -10414,7 +11196,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.chr` through `BuiltinLoweringContext`.", @@ -10523,7 +11305,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.clamp` through `BuiltinLoweringContext`.", @@ -10648,7 +11430,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.class_alias` through `BuiltinLoweringContext`.", @@ -10783,7 +11565,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.class_attribute_args` through `BuiltinLoweringContext`.", @@ -10905,7 +11687,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.class_attribute_names` through `BuiltinLoweringContext`.", @@ -11026,7 +11808,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.class_exists` through `BuiltinLoweringContext`.", @@ -11148,7 +11930,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.class_get_attributes` through `BuiltinLoweringContext`.", @@ -11269,7 +12051,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.class_implements` through `BuiltinLoweringContext`.", @@ -11397,7 +12179,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.class_parents` through `BuiltinLoweringContext`.", @@ -11525,7 +12307,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.class_uses` through `BuiltinLoweringContext`.", @@ -11653,7 +12435,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.clearstatcache` through `BuiltinLoweringContext`.", @@ -11774,7 +12556,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.closedir` through `BuiltinLoweringContext`.", @@ -11895,7 +12677,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.copy` through `BuiltinLoweringContext`.", @@ -12016,7 +12798,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.cos` through `BuiltinLoweringContext`.", @@ -12113,7 +12895,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.cosh` through `BuiltinLoweringContext`.", @@ -12216,7 +12998,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.count` through `BuiltinLoweringContext`.", @@ -12320,7 +13102,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.crc32` through `BuiltinLoweringContext`.", @@ -12417,7 +13199,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ctype_alnum` through `BuiltinLoweringContext`.", @@ -12514,7 +13296,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ctype_alpha` through `BuiltinLoweringContext`.", @@ -12611,7 +13393,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ctype_digit` through `BuiltinLoweringContext`.", @@ -12708,7 +13490,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ctype_space` through `BuiltinLoweringContext`.", @@ -12811,7 +13593,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.date` through `BuiltinLoweringContext`.", @@ -12925,7 +13707,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.date_default_timezone_get` through `BuiltinLoweringContext`.", @@ -13016,7 +13798,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.date_default_timezone_set` through `BuiltinLoweringContext`.", @@ -13136,7 +13918,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.define` through `BuiltinLoweringContext`.", @@ -13258,7 +14040,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.defined` through `BuiltinLoweringContext`.", @@ -13358,7 +14140,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.deg2rad` through `BuiltinLoweringContext`.", @@ -13519,7 +14301,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.dirname` through `BuiltinLoweringContext`.", @@ -13641,7 +14423,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.disk_free_space` through `BuiltinLoweringContext`.", @@ -13755,7 +14537,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.disk_total_space` through `BuiltinLoweringContext`.", @@ -13937,7 +14719,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.enum_exists` through `BuiltinLoweringContext`.", @@ -14059,7 +14841,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.exec` through `BuiltinLoweringContext`.", @@ -14231,7 +15013,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.exp` through `BuiltinLoweringContext`.", @@ -14340,7 +15122,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.explode` through `BuiltinLoweringContext`.", @@ -14452,7 +15234,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.extension_loaded` through `BuiltinLoweringContext`.", @@ -14567,7 +15349,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fclose` through `BuiltinLoweringContext`.", @@ -14682,7 +15464,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fdatasync` through `BuiltinLoweringContext`.", @@ -14803,7 +15585,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fdiv` through `BuiltinLoweringContext`.", @@ -14907,7 +15689,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.feof` through `BuiltinLoweringContext`.", @@ -15022,7 +15804,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fflush` through `BuiltinLoweringContext`.", @@ -15137,7 +15919,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fgetc` through `BuiltinLoweringContext`.", @@ -15264,7 +16046,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fgetcsv` through `BuiltinLoweringContext`.", @@ -15393,7 +16175,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fgets` through `BuiltinLoweringContext`.", @@ -15508,7 +16290,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.file` through `BuiltinLoweringContext`.", @@ -15623,7 +16405,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.file_exists` through `BuiltinLoweringContext`.", @@ -15737,7 +16519,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.file_get_contents` through `BuiltinLoweringContext`.", @@ -15857,7 +16639,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.file_put_contents` through `BuiltinLoweringContext`.", @@ -15978,7 +16760,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fileatime` through `BuiltinLoweringContext`.", @@ -16093,7 +16875,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.filectime` through `BuiltinLoweringContext`.", @@ -16208,7 +16990,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.filegroup` through `BuiltinLoweringContext`.", @@ -16323,7 +17105,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fileinode` through `BuiltinLoweringContext`.", @@ -16438,7 +17220,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.filemtime` through `BuiltinLoweringContext`.", @@ -16552,7 +17334,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fileowner` through `BuiltinLoweringContext`.", @@ -16667,7 +17449,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fileperms` through `BuiltinLoweringContext`.", @@ -16782,7 +17564,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.filesize` through `BuiltinLoweringContext`.", @@ -16896,7 +17678,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.filetype` through `BuiltinLoweringContext`.", @@ -17011,7 +17793,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -17113,7 +17895,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.flock` through `BuiltinLoweringContext`.", @@ -17242,7 +18024,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.floor` through `BuiltinLoweringContext`.", @@ -17345,7 +18127,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fmod` through `BuiltinLoweringContext`.", @@ -17461,7 +18243,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fnmatch` through `BuiltinLoweringContext`.", @@ -17608,7 +18390,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fopen` through `BuiltinLoweringContext`.", @@ -17743,7 +18525,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fpassthru` through `BuiltinLoweringContext`.", @@ -17864,7 +18646,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fprintf` through `BuiltinLoweringContext`.", @@ -18004,7 +18786,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fputcsv` through `BuiltinLoweringContext`.", @@ -18146,7 +18928,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fread` through `BuiltinLoweringContext`.", @@ -18274,7 +19056,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fscanf` through `BuiltinLoweringContext`.", @@ -18408,7 +19190,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fseek` through `BuiltinLoweringContext`.", @@ -18560,7 +19342,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fsockopen` through `BuiltinLoweringContext`.", @@ -18703,7 +19485,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fstat` through `BuiltinLoweringContext`.", @@ -18818,7 +19600,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fsync` through `BuiltinLoweringContext`.", @@ -18933,7 +19715,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ftell` through `BuiltinLoweringContext`.", @@ -19054,7 +19836,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ftruncate` through `BuiltinLoweringContext`.", @@ -19176,7 +19958,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.function_exists` through `BuiltinLoweringContext`.", @@ -19282,7 +20064,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.fwrite` through `BuiltinLoweringContext`.", @@ -19365,7 +20147,7 @@ "type": "string" } ], - "return_type": "int", + "return_type": "mixed", "variadic": null }, "slug": "fwrite", @@ -19447,7 +20229,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.get_class` through `BuiltinLoweringContext`.", @@ -19655,7 +20437,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.get_declared_classes` through `BuiltinLoweringContext`.", @@ -19755,7 +20537,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.get_declared_interfaces` through `BuiltinLoweringContext`.", @@ -19855,7 +20637,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.get_declared_traits` through `BuiltinLoweringContext`.", @@ -19962,7 +20744,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.get_loaded_extensions` through `BuiltinLoweringContext`.", @@ -20135,7 +20917,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.get_parent_class` through `BuiltinLoweringContext`.", @@ -20234,7 +21016,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.get_resource_id` through `BuiltinLoweringContext`.", @@ -20331,7 +21113,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.get_resource_type` through `BuiltinLoweringContext`.", @@ -20421,7 +21203,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.getcwd` through `BuiltinLoweringContext`.", @@ -20527,7 +21309,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.getdate` through `BuiltinLoweringContext`.", @@ -20641,7 +21423,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.getenv` through `BuiltinLoweringContext`.", @@ -20742,7 +21524,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.gethostbyaddr` through `BuiltinLoweringContext`.", @@ -20857,7 +21639,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.gethostbyname` through `BuiltinLoweringContext`.", @@ -20964,7 +21746,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.gethostname` through `BuiltinLoweringContext`.", @@ -21056,7 +21838,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.getprotobyname` through `BuiltinLoweringContext`.", @@ -21171,7 +21953,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.getprotobynumber` through `BuiltinLoweringContext`.", @@ -21292,7 +22074,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.getservbyname` through `BuiltinLoweringContext`.", @@ -21420,7 +22202,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.getservbyport` through `BuiltinLoweringContext`.", @@ -21542,7 +22324,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.gettype` through `BuiltinLoweringContext`.", @@ -21639,7 +22421,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.glob` through `BuiltinLoweringContext`.", @@ -21760,7 +22542,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.gmdate` through `BuiltinLoweringContext`.", @@ -21911,7 +22693,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.gmmktime` through `BuiltinLoweringContext`.", @@ -22060,7 +22842,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.grapheme_strrev` through `BuiltinLoweringContext`.", @@ -22164,7 +22946,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.gzcompress` through `BuiltinLoweringContext`.", @@ -22296,7 +23078,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.gzdeflate` through `BuiltinLoweringContext`.", @@ -22428,7 +23210,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.gzinflate` through `BuiltinLoweringContext`.", @@ -22561,7 +23343,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.gzuncompress` through `BuiltinLoweringContext`.", @@ -22700,7 +23482,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.hash` through `BuiltinLoweringContext`.", @@ -22826,7 +23608,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.hash_algos` through `BuiltinLoweringContext`.", @@ -22980,7 +23762,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.hash_equals` through `BuiltinLoweringContext`.", @@ -23096,7 +23878,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.hash_file` through `BuiltinLoweringContext`.", @@ -23319,7 +24101,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.hash_hmac` through `BuiltinLoweringContext`.", @@ -23626,7 +24408,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.header` through `BuiltinLoweringContext`.", @@ -23754,7 +24536,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.hex_to_bin` through `BuiltinLoweringContext`.", @@ -23850,7 +24632,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.hrtime` through `BuiltinLoweringContext`.", @@ -23950,7 +24732,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.html_entity_decode` through `BuiltinLoweringContext`.", @@ -24056,7 +24838,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.htmlentities` through `BuiltinLoweringContext`.", @@ -24179,7 +24961,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.htmlspecialchars` through `BuiltinLoweringContext`.", @@ -24290,7 +25072,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.http_response_code` through `BuiltinLoweringContext`.", @@ -24410,7 +25192,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.hypot` through `BuiltinLoweringContext`.", @@ -24520,7 +25302,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.implode` through `BuiltinLoweringContext`.", @@ -24637,7 +25419,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.in_array` through `BuiltinLoweringContext`.", @@ -24766,7 +25548,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.inet_ntop` through `BuiltinLoweringContext`.", @@ -24864,7 +25646,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.inet_pton` through `BuiltinLoweringContext`.", @@ -24968,7 +25750,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.intdiv` through `BuiltinLoweringContext`.", @@ -25095,7 +25877,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.interface_exists` through `BuiltinLoweringContext`.", @@ -25217,7 +25999,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -25308,7 +26090,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ip2long` through `BuiltinLoweringContext`.", @@ -25418,7 +26200,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_a` through `BuiltinLoweringContext`.", @@ -25546,7 +26328,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -25637,7 +26419,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -25740,7 +26522,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_callable` through `BuiltinLoweringContext`.", @@ -25854,7 +26636,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_dir` through `BuiltinLoweringContext`.", @@ -25968,7 +26750,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -26059,7 +26841,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_executable` through `BuiltinLoweringContext`.", @@ -26173,7 +26955,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_file` through `BuiltinLoweringContext`.", @@ -26287,7 +27069,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_finite` through `BuiltinLoweringContext`.", @@ -26384,7 +27166,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -26475,7 +27257,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_infinite` through `BuiltinLoweringContext`.", @@ -26572,7 +27354,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -26663,7 +27445,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -26754,7 +27536,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -26845,7 +27627,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_link` through `BuiltinLoweringContext`.", @@ -26959,7 +27741,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -27050,7 +27832,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_nan` through `BuiltinLoweringContext`.", @@ -27147,7 +27929,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -27239,7 +28021,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_numeric` through `BuiltinLoweringContext`.", @@ -27336,7 +28118,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -27427,7 +28209,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_readable` through `BuiltinLoweringContext`.", @@ -27541,7 +28323,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -27632,7 +28414,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -27723,7 +28505,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -27814,7 +28596,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -27917,7 +28699,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_subclass_of` through `BuiltinLoweringContext`.", @@ -28045,7 +28827,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_writable` through `BuiltinLoweringContext`.", @@ -28159,7 +28941,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.is_writeable` through `BuiltinLoweringContext`.", @@ -28345,7 +29127,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.iterator_apply` through `BuiltinLoweringContext`.", @@ -28474,7 +29256,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.iterator_count` through `BuiltinLoweringContext`.", @@ -28595,7 +29377,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.iterator_to_array` through `BuiltinLoweringContext`.", @@ -28735,7 +29517,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.json_decode` through `BuiltinLoweringContext`.", @@ -28883,7 +29665,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.json_encode` through `BuiltinLoweringContext`.", @@ -29005,7 +29787,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.json_last_error` through `BuiltinLoweringContext`.", @@ -29089,7 +29871,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.json_last_error_msg` through `BuiltinLoweringContext`.", @@ -29192,7 +29974,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.json_validate` through `BuiltinLoweringContext`.", @@ -29320,7 +30102,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.krsort` through `BuiltinLoweringContext`.", @@ -29434,7 +30216,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ksort` through `BuiltinLoweringContext`.", @@ -29549,7 +30331,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.lcfirst` through `BuiltinLoweringContext`.", @@ -29652,7 +30434,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.lchgrp` through `BuiltinLoweringContext`.", @@ -29780,7 +30562,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.lchown` through `BuiltinLoweringContext`.", @@ -29908,7 +30690,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.link` through `BuiltinLoweringContext`.", @@ -30029,7 +30811,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.linkinfo` through `BuiltinLoweringContext`.", @@ -30149,7 +30931,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.localtime` through `BuiltinLoweringContext`.", @@ -30276,7 +31058,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.log` through `BuiltinLoweringContext`.", @@ -30380,7 +31162,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.log10` through `BuiltinLoweringContext`.", @@ -30477,7 +31259,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.log2` through `BuiltinLoweringContext`.", @@ -30574,7 +31356,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.long2ip` through `BuiltinLoweringContext`.", @@ -30671,7 +31453,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.lstat` through `BuiltinLoweringContext`.", @@ -30792,7 +31574,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ltrim` through `BuiltinLoweringContext`.", @@ -30896,7 +31678,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.max` through `BuiltinLoweringContext`.", @@ -31006,7 +31788,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.mb_ereg_match` through `BuiltinLoweringContext`.", @@ -31143,7 +31925,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.mb_strlen` through `BuiltinLoweringContext`.", @@ -31276,7 +32058,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.md5` through `BuiltinLoweringContext`.", @@ -31391,7 +32173,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.method_exists` through `BuiltinLoweringContext`.", @@ -31512,7 +32294,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.microtime` through `BuiltinLoweringContext`.", @@ -31613,7 +32395,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.min` through `BuiltinLoweringContext`.", @@ -31711,7 +32493,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.mkdir` through `BuiltinLoweringContext`.", @@ -31855,7 +32637,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.mktime` through `BuiltinLoweringContext`.", @@ -32010,7 +32792,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.mt_rand` through `BuiltinLoweringContext`.", @@ -32117,7 +32899,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.natcasesort` through `BuiltinLoweringContext`.", @@ -32231,7 +33013,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.natsort` through `BuiltinLoweringContext`.", @@ -32352,7 +33134,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.nl_to_br` through `BuiltinLoweringContext`.", @@ -32464,7 +33246,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.number_format` through `BuiltinLoweringContext`.", @@ -32575,7 +33357,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_clean` through `BuiltinLoweringContext`.", @@ -32674,7 +33456,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_end_clean` through `BuiltinLoweringContext`.", @@ -32773,7 +33555,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_end_flush` through `BuiltinLoweringContext`.", @@ -32872,7 +33654,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_flush` through `BuiltinLoweringContext`.", @@ -32971,7 +33753,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_get_clean` through `BuiltinLoweringContext`.", @@ -33071,7 +33853,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_get_contents` through `BuiltinLoweringContext`.", @@ -33171,7 +33953,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_get_flush` through `BuiltinLoweringContext`.", @@ -33271,7 +34053,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_get_length` through `BuiltinLoweringContext`.", @@ -33371,7 +34153,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_get_level` through `BuiltinLoweringContext`.", @@ -33462,7 +34244,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_get_status` through `BuiltinLoweringContext`.", @@ -33577,7 +34359,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_implicit_flush` through `BuiltinLoweringContext`.", @@ -33684,7 +34466,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_list_handlers` through `BuiltinLoweringContext`.", @@ -33803,7 +34585,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ob_start` through `BuiltinLoweringContext`.", @@ -33932,7 +34714,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.opendir` through `BuiltinLoweringContext`.", @@ -34047,7 +34829,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ord` through `BuiltinLoweringContext`.", @@ -34150,7 +34932,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.parse_url` through `BuiltinLoweringContext`.", @@ -34257,7 +35039,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.passthru` through `BuiltinLoweringContext`.", @@ -34377,7 +35159,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.pathinfo` through `BuiltinLoweringContext`.", @@ -34499,7 +35281,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.pclose` through `BuiltinLoweringContext`.", @@ -34637,7 +35419,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.pfsockopen` through `BuiltinLoweringContext`.", @@ -34780,7 +35562,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.php_uname` through `BuiltinLoweringContext`.", @@ -34882,7 +35664,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.phpversion` through `BuiltinLoweringContext`.", @@ -34973,7 +35755,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.pi` through `BuiltinLoweringContext`.", @@ -35068,7 +35850,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.popen` through `BuiltinLoweringContext`.", @@ -35196,7 +35978,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.pow` through `BuiltinLoweringContext`.", @@ -35318,7 +36100,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.preg_match` through `BuiltinLoweringContext`.", @@ -35465,7 +36247,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.preg_match_all` through `BuiltinLoweringContext`.", @@ -35598,7 +36380,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.preg_replace` through `BuiltinLoweringContext`.", @@ -35738,7 +36520,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.preg_replace_callback` through `BuiltinLoweringContext`.", @@ -35885,7 +36667,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.preg_split` through `BuiltinLoweringContext`.", @@ -36027,7 +36809,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.print_r` through `BuiltinLoweringContext`.", @@ -36149,7 +36931,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.printf` through `BuiltinLoweringContext`.", @@ -36269,7 +37051,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.property_exists` through `BuiltinLoweringContext`.", @@ -36390,7 +37172,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr` through `BuiltinLoweringContext`.", @@ -36505,7 +37287,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_get` through `BuiltinLoweringContext`.", @@ -36620,7 +37402,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_is_null` through `BuiltinLoweringContext`.", @@ -36728,7 +37510,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_null` through `BuiltinLoweringContext`.", @@ -36841,7 +37623,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_offset` through `BuiltinLoweringContext`.", @@ -36963,7 +37745,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_read16` through `BuiltinLoweringContext`.", @@ -37078,7 +37860,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_read32` through `BuiltinLoweringContext`.", @@ -37193,7 +37975,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_read8` through `BuiltinLoweringContext`.", @@ -37314,7 +38096,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_read_string` through `BuiltinLoweringContext`.", @@ -37442,7 +38224,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_set` through `BuiltinLoweringContext`.", @@ -37564,7 +38346,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_sizeof` through `BuiltinLoweringContext`.", @@ -37685,7 +38467,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_write16` through `BuiltinLoweringContext`.", @@ -37813,7 +38595,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_write32` through `BuiltinLoweringContext`.", @@ -37941,7 +38723,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_write8` through `BuiltinLoweringContext`.", @@ -38069,7 +38851,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ptr_write_string` through `BuiltinLoweringContext`.", @@ -38191,7 +38973,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.putenv` through `BuiltinLoweringContext`.", @@ -38305,7 +39087,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.rad2deg` through `BuiltinLoweringContext`.", @@ -38408,7 +39190,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.rand` through `BuiltinLoweringContext`.", @@ -38522,7 +39304,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.random_int` through `BuiltinLoweringContext`.", @@ -38635,7 +39417,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.range` through `BuiltinLoweringContext`.", @@ -38740,7 +39522,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.raw_url_decode` through `BuiltinLoweringContext`.", @@ -38834,7 +39616,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.raw_url_encode` through `BuiltinLoweringContext`.", @@ -38928,7 +39710,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.readdir` through `BuiltinLoweringContext`.", @@ -39043,7 +39825,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.readfile` through `BuiltinLoweringContext`.", @@ -39158,7 +39940,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.readline` through `BuiltinLoweringContext`.", @@ -39273,7 +40055,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.readlink` through `BuiltinLoweringContext`.", @@ -39388,7 +40170,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.realpath` through `BuiltinLoweringContext`.", @@ -39496,7 +40278,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.realpath_cache_get` through `BuiltinLoweringContext`.", @@ -39596,7 +40378,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.realpath_cache_size` through `BuiltinLoweringContext`.", @@ -39708,7 +40490,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.rename` through `BuiltinLoweringContext`.", @@ -39829,7 +40611,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.rewind` through `BuiltinLoweringContext`.", @@ -39944,7 +40726,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.rewinddir` through `BuiltinLoweringContext`.", @@ -40059,7 +40841,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.rmdir` through `BuiltinLoweringContext`.", @@ -40179,7 +40961,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.round` through `BuiltinLoweringContext`.", @@ -40282,7 +41064,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.rsort` through `BuiltinLoweringContext`.", @@ -40403,7 +41185,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.rtrim` through `BuiltinLoweringContext`.", @@ -40507,7 +41289,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.scandir` through `BuiltinLoweringContext`.", @@ -40606,7 +41388,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.serialize` through `BuiltinLoweringContext`.", @@ -40725,7 +41507,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.settype` through `BuiltinLoweringContext`.", @@ -40853,7 +41635,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.sha1` through `BuiltinLoweringContext`.", @@ -40962,7 +41744,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.shell_exec` through `BuiltinLoweringContext`.", @@ -41075,7 +41857,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.shuffle` through `BuiltinLoweringContext`.", @@ -41190,7 +41972,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.sin` through `BuiltinLoweringContext`.", @@ -41287,7 +42069,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.sinh` through `BuiltinLoweringContext`.", @@ -41384,7 +42166,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.sleep` through `BuiltinLoweringContext`.", @@ -41482,7 +42264,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.sort` through `BuiltinLoweringContext`.", @@ -41603,7 +42385,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.spl_autoload` through `BuiltinLoweringContext`.", @@ -41724,7 +42506,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.spl_autoload_call` through `BuiltinLoweringContext`.", @@ -41838,7 +42620,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.spl_autoload_extensions` through `BuiltinLoweringContext`.", @@ -41932,7 +42714,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.spl_autoload_functions` through `BuiltinLoweringContext`.", @@ -42037,7 +42819,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.spl_autoload_register` through `BuiltinLoweringContext`.", @@ -42165,7 +42947,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.spl_autoload_unregister` through `BuiltinLoweringContext`.", @@ -42272,7 +43054,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.spl_classes` through `BuiltinLoweringContext`.", @@ -42379,7 +43161,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.spl_object_hash` through `BuiltinLoweringContext`.", @@ -42480,7 +43262,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.spl_object_id` through `BuiltinLoweringContext`.", @@ -42580,7 +43362,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.sprintf` through `BuiltinLoweringContext`.", @@ -42681,7 +43463,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.sqrt` through `BuiltinLoweringContext`.", @@ -42784,7 +43566,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.sscanf` through `BuiltinLoweringContext`.", @@ -42906,7 +43688,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stat` through `BuiltinLoweringContext`.", @@ -43027,7 +43809,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.str_contains` through `BuiltinLoweringContext`.", @@ -43137,7 +43919,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.str_ends_with` through `BuiltinLoweringContext`.", @@ -43259,7 +44041,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.str_ireplace` through `BuiltinLoweringContext`.", @@ -43395,7 +44177,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.str_pad` through `BuiltinLoweringContext`.", @@ -43519,7 +44301,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.str_repeat` through `BuiltinLoweringContext`.", @@ -43641,7 +44423,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.str_replace` through `BuiltinLoweringContext`.", @@ -43765,7 +44547,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.str_split` through `BuiltinLoweringContext`.", @@ -43876,7 +44658,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.str_starts_with` through `BuiltinLoweringContext`.", @@ -43986,7 +44768,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.strcasecmp` through `BuiltinLoweringContext`.", @@ -44096,7 +44878,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.strcmp` through `BuiltinLoweringContext`.", @@ -44206,7 +44988,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_bucket_append` through `BuiltinLoweringContext`.", @@ -44327,7 +45109,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_bucket_make_writeable` through `BuiltinLoweringContext`.", @@ -44447,7 +45229,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_bucket_new` through `BuiltinLoweringContext`.", @@ -44574,7 +45356,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_bucket_prepend` through `BuiltinLoweringContext`.", @@ -44701,7 +45483,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_context_create` through `BuiltinLoweringContext`.", @@ -44823,7 +45605,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_context_get_default` through `BuiltinLoweringContext`.", @@ -44938,7 +45720,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_context_get_options` through `BuiltinLoweringContext`.", @@ -45053,7 +45835,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_context_get_params` through `BuiltinLoweringContext`.", @@ -45168,7 +45950,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_context_set_default` through `BuiltinLoweringContext`.", @@ -45301,7 +46083,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_context_set_option` through `BuiltinLoweringContext`.", @@ -45442,7 +46224,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_context_set_params` through `BuiltinLoweringContext`.", @@ -45581,7 +46363,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_copy_to_stream` through `BuiltinLoweringContext`.", @@ -45735,7 +46517,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_filter_append` through `BuiltinLoweringContext`.", @@ -45888,7 +46670,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_filter_prepend` through `BuiltinLoweringContext`.", @@ -46029,7 +46811,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_filter_register` through `BuiltinLoweringContext`.", @@ -46151,7 +46933,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_filter_remove` through `BuiltinLoweringContext`.", @@ -46278,7 +47060,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_get_contents` through `BuiltinLoweringContext`.", @@ -46400,7 +47182,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_get_filters` through `BuiltinLoweringContext`.", @@ -46519,7 +47301,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_get_line` through `BuiltinLoweringContext`.", @@ -46648,7 +47430,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_get_meta_data` through `BuiltinLoweringContext`.", @@ -46756,7 +47538,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_get_transports` through `BuiltinLoweringContext`.", @@ -46856,7 +47638,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_get_wrappers` through `BuiltinLoweringContext`.", @@ -46963,7 +47745,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_is_local` through `BuiltinLoweringContext`.", @@ -47077,7 +47859,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_isatty` through `BuiltinLoweringContext`.", @@ -47192,7 +47974,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_resolve_include_path` through `BuiltinLoweringContext`.", @@ -47329,7 +48111,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_select` through `BuiltinLoweringContext`.", @@ -47477,7 +48259,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_set_blocking` through `BuiltinLoweringContext`.", @@ -47605,7 +48387,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_set_chunk_size` through `BuiltinLoweringContext`.", @@ -47732,7 +48514,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_set_read_buffer` through `BuiltinLoweringContext`.", @@ -47865,7 +48647,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_set_timeout` through `BuiltinLoweringContext`.", @@ -48000,7 +48782,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_set_write_buffer` through `BuiltinLoweringContext`.", @@ -48132,7 +48914,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_socket_accept` through `BuiltinLoweringContext`.", @@ -48261,7 +49043,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_socket_client` through `BuiltinLoweringContext`.", @@ -48394,7 +49176,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_socket_enable_crypto` through `BuiltinLoweringContext`.", @@ -48541,7 +49323,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_socket_get_name` through `BuiltinLoweringContext`.", @@ -48675,7 +49457,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_socket_pair` through `BuiltinLoweringContext`.", @@ -48820,7 +49602,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_socket_recvfrom` through `BuiltinLoweringContext`.", @@ -48974,7 +49756,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_socket_sendto` through `BuiltinLoweringContext`.", @@ -49110,7 +49892,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_socket_server` through `BuiltinLoweringContext`.", @@ -49231,7 +50013,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_socket_shutdown` through `BuiltinLoweringContext`.", @@ -49353,7 +50135,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_supports_lock` through `BuiltinLoweringContext`.", @@ -49480,7 +50262,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_wrapper_register` through `BuiltinLoweringContext`.", @@ -49609,7 +50391,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_wrapper_restore` through `BuiltinLoweringContext`.", @@ -49723,7 +50505,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.stream_wrapper_unregister` through `BuiltinLoweringContext`.", @@ -49837,7 +50619,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.strip_slashes` through `BuiltinLoweringContext`.", @@ -49931,7 +50713,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_graph` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -50034,7 +50816,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.strpos` through `BuiltinLoweringContext`.", @@ -50146,7 +50928,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.reverse` through `BuiltinLoweringContext`.", @@ -50252,7 +51034,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.strrpos` through `BuiltinLoweringContext`.", @@ -50376,7 +51158,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.strstr` through `BuiltinLoweringContext`.", @@ -50488,7 +51270,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.to_lower` through `BuiltinLoweringContext`.", @@ -50588,7 +51370,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.strtotime` through `BuiltinLoweringContext`.", @@ -50710,7 +51492,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.to_upper` through `BuiltinLoweringContext`.", @@ -50804,7 +51586,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `eir_primitive` strategy from the single-source builtin descriptor.", "Emits backend-neutral EIR primitives or a small EIR graph through `BuiltinLoweringContext`." @@ -50908,7 +51690,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.substr` through `BuiltinLoweringContext`.", @@ -51037,7 +51819,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.substr_replace` through `BuiltinLoweringContext`.", @@ -51161,7 +51943,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.symlink` through `BuiltinLoweringContext`.", @@ -51275,7 +52057,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.sys_get_temp_dir` through `BuiltinLoweringContext`.", @@ -51381,7 +52163,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.system` through `BuiltinLoweringContext`.", @@ -51495,7 +52277,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.tan` through `BuiltinLoweringContext`.", @@ -51592,7 +52374,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.tanh` through `BuiltinLoweringContext`.", @@ -51695,7 +52477,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.tempnam` through `BuiltinLoweringContext`.", @@ -51809,7 +52591,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.time` through `BuiltinLoweringContext`.", @@ -51893,7 +52675,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.tmpfile` through `BuiltinLoweringContext`.", @@ -52012,7 +52794,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.touch` through `BuiltinLoweringContext`.", @@ -52147,7 +52929,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.trait_exists` through `BuiltinLoweringContext`.", @@ -52275,7 +53057,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.trim` through `BuiltinLoweringContext`.", @@ -52384,7 +53166,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.uasort` through `BuiltinLoweringContext`.", @@ -52506,7 +53288,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ucfirst` through `BuiltinLoweringContext`.", @@ -52609,7 +53391,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.ucwords` through `BuiltinLoweringContext`.", @@ -52718,7 +53500,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.uksort` through `BuiltinLoweringContext`.", @@ -52840,7 +53622,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.umask` through `BuiltinLoweringContext`.", @@ -52954,7 +53736,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.unlink` through `BuiltinLoweringContext`.", @@ -53052,7 +53834,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.unserialize` through `BuiltinLoweringContext`.", @@ -53234,7 +54016,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.url_decode` through `BuiltinLoweringContext`.", @@ -53328,7 +54110,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.string.url_encode` through `BuiltinLoweringContext`.", @@ -53422,7 +54204,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.usleep` through `BuiltinLoweringContext`.", @@ -53526,7 +54308,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.usort` through `BuiltinLoweringContext`.", @@ -53648,7 +54430,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.var_dump` through `BuiltinLoweringContext`.", @@ -53774,7 +54556,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.vfprintf` through `BuiltinLoweringContext`.", @@ -53909,7 +54691,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.vprintf` through `BuiltinLoweringContext`.", @@ -54036,7 +54818,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.vsprintf` through `BuiltinLoweringContext`.", @@ -54162,7 +54944,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.wordwrap` through `BuiltinLoweringContext`.", @@ -54264,7 +55046,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.zval_free` through `BuiltinLoweringContext`.", @@ -54363,7 +55145,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.zval_pack` through `BuiltinLoweringContext`.", @@ -54462,7 +55244,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.zval_type` through `BuiltinLoweringContext`.", @@ -54561,7 +55343,7 @@ "checker_line": null, "codegen_file": "src/builtins/semantics.rs", "codegen_function": "lower_registry_call", - "codegen_line": 423, + "codegen_line": 448, "notes": [ "Uses the `runtime_call` strategy from the single-source builtin descriptor.", "Emits the typed EIR target `runtime.zval_unpack` through `BuiltinLoweringContext`.", diff --git a/scripts/test-pdo-gss.sh b/scripts/test-pdo-gss.sh new file mode 100755 index 0000000000..a343d44b5c --- /dev/null +++ b/scripts/test-pdo-gss.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Runs the focused PDO PostgreSQL GSSAPI integration tests against an ephemeral +# MIT Kerberos realm and PostgreSQL 16 server. +# +# Requirements: Docker, cargo, kinit, and a libpq development package. The +# caller's Cargo target/cache are reused; every Docker resource is removed on exit. + +set -euo pipefail + +REALM="ELEPHC.TEST" +CLIENT_PRINCIPAL="elephc_gss@$REALM" +RUN_ID="$$" +NETWORK="elephc-pdo-gss-$RUN_ID" +KDC_CONTAINER="elephc-pdo-kdc-$RUN_ID" +PG_CONTAINER="elephc-pdo-pg-gss-$RUN_ID" +KDC_PORT="${ELEPHC_GSS_KDC_PORT:-10088}" +PG_PORT="${ELEPHC_GSS_PG_PORT:-55432}" +FIXTURE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/elephc-pdo-gss.XXXXXX")" + +cleanup() { + docker rm -f "$PG_CONTAINER" "$KDC_CONTAINER" >/dev/null 2>&1 || true + docker network rm "$NETWORK" >/dev/null 2>&1 || true + rm -rf "$FIXTURE_DIR" +} + +trap cleanup EXIT INT TERM + +for command in docker cargo kinit; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "PDO GSSAPI test requires '$command'" >&2 + exit 1 + fi +done + +cat >"$FIXTURE_DIR/krb5-kdc.conf" <"$FIXTURE_DIR/krb5-client.conf" <"$FIXTURE_DIR/kdc.conf" <"$FIXTURE_DIR/kadm5.acl" + +docker network create "$NETWORK" >/dev/null + +docker run -d \ + --name "$KDC_CONTAINER" \ + --hostname "$KDC_CONTAINER" \ + --network "$NETWORK" \ + -p "127.0.0.1:$KDC_PORT:88/tcp" \ + -p "127.0.0.1:$KDC_PORT:88/udp" \ + -v "$FIXTURE_DIR:/fixture" \ + debian:bookworm-slim \ + sh -ec ' + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq krb5-kdc krb5-admin-server >/dev/null + install -m 0644 /fixture/krb5-kdc.conf /etc/krb5.conf + install -m 0644 /fixture/kdc.conf /etc/krb5kdc/kdc.conf + install -m 0600 /fixture/kadm5.acl /etc/krb5kdc/kadm5.acl + kdb5_util create -s -P elephc-master-password + kadmin.local -q "addprinc -randkey postgres/pgsql.elephc.test@ELEPHC.TEST" + kadmin.local -q "addprinc -randkey elephc_gss@ELEPHC.TEST" + kadmin.local -q "ktadd -k /fixture/postgres.keytab postgres/pgsql.elephc.test@ELEPHC.TEST" + kadmin.local -q "ktadd -k /fixture/client.keytab elephc_gss@ELEPHC.TEST" + chmod 0644 /fixture/postgres.keytab /fixture/client.keytab + touch /fixture/kdc-ready + exec krb5kdc -n + ' >/dev/null + +for attempt in $(seq 1 90); do + if [ -f "$FIXTURE_DIR/kdc-ready" ]; then + break + fi + if ! docker inspect -f '{{.State.Running}}' "$KDC_CONTAINER" 2>/dev/null | grep -q true; then + docker logs "$KDC_CONTAINER" >&2 + exit 1 + fi + if [ "$attempt" -eq 90 ]; then + docker logs "$KDC_CONTAINER" >&2 + echo "Kerberos KDC did not become ready" >&2 + exit 1 + fi + sleep 2 +done + +docker run -d \ + --name "$PG_CONTAINER" \ + --hostname pgsql.elephc.test \ + --network "$NETWORK" \ + -p "127.0.0.1:$PG_PORT:5432" \ + -e POSTGRES_PASSWORD=test \ + -e POSTGRES_DB=testdb \ + postgres:16 >/dev/null + +for attempt in $(seq 1 60); do + if docker exec "$PG_CONTAINER" pg_isready -U postgres -d testdb >/dev/null 2>&1; then + break + fi + if [ "$attempt" -eq 60 ]; then + docker logs "$PG_CONTAINER" >&2 + echo "PostgreSQL GSSAPI fixture did not become ready" >&2 + exit 1 + fi + sleep 2 +done + +if ! docker exec "$PG_CONTAINER" pg_config --configure | grep -q -- '--with-gssapi'; then + echo "PostgreSQL fixture was built without GSSAPI support" >&2 + exit 1 +fi + +docker cp "$FIXTURE_DIR/krb5-kdc.conf" "$PG_CONTAINER:/tmp/krb5.conf" +docker cp "$FIXTURE_DIR/postgres.keytab" "$PG_CONTAINER:/tmp/postgres.keytab" +docker exec -u root "$PG_CONTAINER" sh -ec ' + install -o postgres -g postgres -m 0644 /tmp/krb5.conf /etc/krb5.conf + install -o postgres -g postgres -m 0600 /tmp/postgres.keytab /var/lib/postgresql/postgres.keytab + sed -i "1ihostgssenc testdb elephc_gss 0.0.0.0/0 gss include_realm=0 krb_realm=ELEPHC.TEST" /var/lib/postgresql/data/pg_hba.conf + printf "\nkrb_server_keyfile = '\''/var/lib/postgresql/postgres.keytab'\''\n" >> /var/lib/postgresql/data/postgresql.conf +' +docker exec "$PG_CONTAINER" \ + psql -U postgres -d testdb -v ON_ERROR_STOP=1 \ + -c 'CREATE ROLE elephc_gss LOGIN' +docker restart "$PG_CONTAINER" >/dev/null + +for attempt in $(seq 1 60); do + if docker exec "$PG_CONTAINER" pg_isready -U postgres -d testdb >/dev/null 2>&1; then + break + fi + if [ "$attempt" -eq 60 ]; then + docker logs "$PG_CONTAINER" >&2 + echo "PostgreSQL GSSAPI fixture did not restart" >&2 + exit 1 + fi + sleep 2 +done + +export KRB5_CONFIG="$FIXTURE_DIR/krb5-client.conf" +export KRB5CCNAME="FILE:$FIXTURE_DIR/client.ccache" +kinit -V -k -t "$FIXTURE_DIR/client.keytab" "$CLIENT_PRINCIPAL" + +export ELEPHC_PDO_LIBPQ=1 +export ELEPHC_PDO_GSS_REQUIRED=1 +export ELEPHC_PG_GSS_DSN="pgsql:host=pgsql.elephc.test;hostaddr=127.0.0.1;port=$PG_PORT;dbname=testdb;user=elephc_gss;gssencmode=require;require_auth=gss;krbsrvname=postgres" +export ELEPHC_PG_GSS_EMPTY_CACHE="FILE:$FIXTURE_DIR/missing.ccache" + +cargo build -p elephc-pdo --features libpq-gss +cargo test --features pdo-libpq-gss --test codegen_tests pgsql_gss -- --ignored --test-threads=1 diff --git a/src/builtins/array/array_key_exists.rs b/src/builtins/array/array_key_exists.rs index 6cd5f8f3bc..702bf1c25a 100644 --- a/src/builtins/array/array_key_exists.rs +++ b/src/builtins/array/array_key_exists.rs @@ -24,14 +24,18 @@ builtin! { php_manual: "https://www.php.net/manual/en/function.array-key-exists.php", } -/// Validates that the second argument is an array and returns `Bool`. +/// Validates that the second argument can carry an array and returns `Bool`. /// /// The registry's `check_arity` handles arity enforcement (exactly 2 arguments). -/// This hook validates that `array` is an array and returns the `Bool` return type. +/// Boxed `Mixed` and union values are accepted because guarded arrays retain their dynamic packed +/// versus associative representation; lowering dispatches their runtime tags to the correct probe. fn check(cx: &mut BuiltinCheckCtx) -> Result { cx.checker.infer_type(&cx.args[0], cx.env)?; let arr_ty = cx.checker.infer_type(&cx.args[1], cx.env)?; - if !matches!(arr_ty, PhpType::Array(_) | PhpType::AssocArray { .. }) { + if !matches!( + arr_ty, + PhpType::Array(_) | PhpType::AssocArray { .. } | PhpType::Mixed | PhpType::Union(_) + ) { return Err(CompileError::new( cx.span, "array_key_exists() second argument must be array", diff --git a/src/builtins/io/fwrite.rs b/src/builtins/io/fwrite.rs index 24e316678f..c6c748369e 100644 --- a/src/builtins/io/fwrite.rs +++ b/src/builtins/io/fwrite.rs @@ -16,7 +16,7 @@ builtin! { name: "fwrite", area: Io, params: [stream: Mixed, data: Str], - returns: Int, + returns: Mixed, check: check, semantics: crate::builtins::semantics::runtime_fn_semantics( crate::ir::RuntimeFnId::Fwrite, @@ -25,7 +25,7 @@ builtin! { php_manual: "function.fwrite", } -/// Validates the stream argument is a stream resource and returns `Int`. +/// Validates the stream argument and returns PHP's `int|false` result union. fn check(cx: &mut BuiltinCheckCtx) -> Result { crate::types::checker::builtins::io::common::ensure_stream_resource( cx.checker, @@ -33,5 +33,7 @@ fn check(cx: &mut BuiltinCheckCtx) -> Result { &cx.args[0], cx.env, )?; - Ok(PhpType::Int) + Ok(cx + .checker + .normalize_union_type(vec![PhpType::Int, PhpType::Bool])) } diff --git a/src/builtins/pointers/elephc_callable_ptr.rs b/src/builtins/pointers/elephc_callable_ptr.rs new file mode 100644 index 0000000000..58777abd35 --- /dev/null +++ b/src/builtins/pointers/elephc_callable_ptr.rs @@ -0,0 +1,66 @@ +//! Purpose: +//! Home of the internal `__elephc_callable_ptr` builtin: it reinterprets a +//! closure / first-class callable value as the raw pointer to its 64-byte callable +//! descriptor. This is the PHP-prelude half of the PDO Tier-D "decompose-at-PHP" +//! callback design: a `callable` is broken into (descriptor pointer, adapter +//! address) so that no bridge extern ever declares a `callable` parameter. +//! +//! Called from: +//! - The builtin registry (declaration), the type checker (check hook), and the EIR +//! backend (lower hook), all via `crate::builtins::registry`. +//! - The PDO prelude driver methods (`Pdo\Sqlite::createCollation`, and later +//! `createFunction` / `createAggregate`). +//! +//! Key details: +//! - `internal: true` keeps it out of PHP-visible catalogs and the parity gate while +//! remaining callable through `registry::is_supported`. +//! - `check` returns `PhpType::Pointer(None)`; the runtime value of a closure / +//! first-class callable already IS its descriptor pointer, so lowering is a bare +//! identity load guarded against string / array callables (whose value is a PHP +//! string, not a descriptor). + +use crate::builtins::spec::BuiltinCheckCtx; +use crate::errors::CompileError; +use crate::builtins::semantics::{ + internal_eir_semantics, BuiltinLoweringContext, BuiltinResultOwnership, + LoweredBuiltinValue, NormalizedBuiltinCall, +}; +use crate::ir::{Effects, Op}; +use crate::types::PhpType; + +builtin! { + name: "__elephc_callable_ptr", + area: Pointers, + params: [value: Mixed], + returns: Mixed, + check: check, + semantics: internal_eir_semantics(lower, Effects::PURE, BuiltinResultOwnership::NonHeap), + summary: "Reinterprets a closure / first-class callable as its raw descriptor pointer.", + internal: true +} + +/// Infers the argument type and returns `PhpType::Pointer(None)`. +/// +/// The static callable kind (closure / first-class vs string / array) is not carried +/// by `PhpType::Callable`, so the string / array rejection happens at lowering where +/// the value's codegen type is available. The registry's `check_arity` enforces the +/// single-argument arity. +fn check(cx: &mut BuiltinCheckCtx) -> Result { + cx.checker.infer_type(&cx.args[0], cx.env)?; + Ok(PhpType::Pointer(None)) +} + +/// Lowers a normalized callable value to the dedicated descriptor-pointer EIR primitive. +fn lower( + ctx: &mut dyn BuiltinLoweringContext, + call: &NormalizedBuiltinCall<'_>, +) -> Result { + Ok(ctx.emit_value( + Op::CallablePtr, + vec![call.operand(0)?], + None, + call.result_type.clone(), + Op::CallablePtr.default_effects(), + Some(call.span), + )) +} diff --git a/src/builtins/pointers/elephc_normalize_callable.rs b/src/builtins/pointers/elephc_normalize_callable.rs new file mode 100644 index 0000000000..6ba1d2d7d4 --- /dev/null +++ b/src/builtins/pointers/elephc_normalize_callable.rs @@ -0,0 +1,55 @@ +//! Purpose: +//! Declares the internal callable-normalization builtin used by native callback bridges. +//! It converts every PHP callable form into an owned runtime callable descriptor. +//! +//! Called from: +//! - The generated PDO prelude before SQLite stores a callback descriptor pointer. +//! +//! Key details: +//! - The returned `Callable` owns or retains its descriptor until ordinary PHP cleanup releases it. +//! - `internal: true` keeps this compiler primitive out of PHP-visible builtin catalogs. + +use crate::builtins::spec::BuiltinCheckCtx; +use crate::builtins::semantics::{ + internal_eir_semantics, BuiltinLoweringContext, BuiltinResultOwnership, + LoweredBuiltinValue, NormalizedBuiltinCall, +}; +use crate::errors::CompileError; +use crate::ir::{Effects, Op}; +use crate::types::PhpType; + +builtin! { + name: "__elephc_normalize_callable", + area: Pointers, + params: [value: Mixed], + returns: Mixed, + check: check, + semantics: internal_eir_semantics( + lower, + Effects::READS_HEAP.union(Effects::ALLOC_HEAP).union(Effects::REFCOUNT_OP), + BuiltinResultOwnership::Fresh, + ), + summary: "Normalizes a PHP callable into an owned runtime descriptor.", + internal: true +} + +/// Infers the source expression and exposes the owned callable result type. +fn check(cx: &mut BuiltinCheckCtx) -> Result { + cx.checker.infer_type(&cx.args[0], cx.env)?; + Ok(PhpType::Callable) +} + +/// Lowers callable normalization to the dedicated owned-descriptor EIR primitive. +fn lower( + ctx: &mut dyn BuiltinLoweringContext, + call: &NormalizedBuiltinCall<'_>, +) -> Result { + Ok(ctx.emit_value( + Op::NormalizeCallable, + vec![call.operand(0)?], + None, + call.result_type.clone(), + Op::NormalizeCallable.default_effects(), + Some(call.span), + )) +} diff --git a/src/builtins/pointers/elephc_pdo_adapter_addr.rs b/src/builtins/pointers/elephc_pdo_adapter_addr.rs new file mode 100644 index 0000000000..b4d699d1ff --- /dev/null +++ b/src/builtins/pointers/elephc_pdo_adapter_addr.rs @@ -0,0 +1,69 @@ +//! Purpose: +//! Home of the internal `__elephc_pdo_adapter_addr` builtin: it materializes the +//! address of a shared codegen PDO callback adapter (`__rt_pdo_*`) selected by a +//! constant kind. This is the second half of the PDO Tier-D "decompose-at-PHP" +//! design — the prelude hands the bridge (descriptor pointer, adapter address) as +//! two plain `ptr` arguments, and the bridge calls the adapter back with the +//! database-provided values without ever referencing a `__rt_*` symbol itself. +//! +//! Called from: +//! - The builtin registry (declaration), the type checker (check hook), and the EIR +//! backend (lower hook), all via `crate::builtins::registry`. +//! - The PDO prelude driver methods (`Pdo\Sqlite::createCollation`, and later +//! `createFunction` / `createAggregate`). +//! +//! Key details: +//! - `internal: true` keeps it out of PHP-visible catalogs and the parity gate. +//! - `check` returns `PhpType::Pointer(None)`; lowering reads the constant kind and +//! emits the GOT address of the corresponding `__rt_pdo_*` adapter (kind 0 = +//! collation). + +use crate::builtins::spec::BuiltinCheckCtx; +use crate::builtins::semantics::{ + internal_eir_semantics, BuiltinLoweringContext, BuiltinResultOwnership, + LoweredBuiltinValue, NormalizedBuiltinCall, +}; +use crate::errors::CompileError; +use crate::ir::{Effects, Op}; +use crate::types::PhpType; + +builtin! { + name: "__elephc_pdo_adapter_addr", + area: Pointers, + params: [kind: Int], + returns: Mixed, + check: check, + semantics: internal_eir_semantics(lower, Effects::PURE, BuiltinResultOwnership::NonHeap), + summary: "Returns the address of the shared __rt_pdo_* callback adapter for a kind.", + internal: true +} + +/// Validates that the kind argument is integer-compatible and returns the pointer type. +/// +/// The registry's `check_arity` enforces the single-argument arity; the kind must be +/// a constant integer literal, which the lowering hook re-validates. +fn check(cx: &mut BuiltinCheckCtx) -> Result { + let kind_ty = cx.checker.infer_type(&cx.args[0], cx.env)?; + if !matches!(kind_ty, PhpType::Int | PhpType::Mixed | PhpType::Union(_)) { + return Err(CompileError::new( + cx.span, + "__elephc_pdo_adapter_addr() argument must be an integer kind", + )); + } + Ok(PhpType::Pointer(None)) +} + +/// Lowers one callback-adapter selector to its dedicated address-producing EIR primitive. +fn lower( + ctx: &mut dyn BuiltinLoweringContext, + call: &NormalizedBuiltinCall<'_>, +) -> Result { + Ok(ctx.emit_value( + Op::PdoAdapterAddr, + vec![call.operand(0)?], + None, + call.result_type.clone(), + Op::PdoAdapterAddr.default_effects(), + Some(call.span), + )) +} diff --git a/src/builtins/pointers/mod.rs b/src/builtins/pointers/mod.rs index 9256439bcf..072926e6b5 100644 --- a/src/builtins/pointers/mod.rs +++ b/src/builtins/pointers/mod.rs @@ -17,6 +17,9 @@ pub mod __elephc_ptr_read_string; pub mod __elephc_ptr_write_string; pub mod buffer_free; pub mod buffer_len; +pub mod elephc_callable_ptr; +pub mod elephc_normalize_callable; +pub mod elephc_pdo_adapter_addr; pub mod ptr; pub mod ptr_get; pub mod ptr_is_null; diff --git a/src/builtins/semantics.rs b/src/builtins/semantics.rs index e4c6ed4b55..cefe5592d4 100644 --- a/src/builtins/semantics.rs +++ b/src/builtins/semantics.rs @@ -394,6 +394,31 @@ pub const fn type_predicate_semantics(predicate: PhpTypePredicate) -> BuiltinSem } } +/// Builds the shared descriptor for an internal compiler primitive lowered to one EIR operation. +/// +/// Internal primitives are direct-call-only implementation details used by generated preludes. +/// Callers provide the conservative effect and ownership contracts because those differ between +/// pointer identities, fresh callable/object values, and constructor-invoking operations. +pub const fn internal_eir_semantics( + lower: BuiltinLowerFn, + effects: Effects, + result_ownership: BuiltinResultOwnership, +) -> BuiltinSemantics { + BuiltinSemantics { + validation: BuiltinValidation::SignatureOnly, + result_type: BuiltinResultType::Declared, + effects: BuiltinEffects::Static(effects), + result_ownership, + requirements: BuiltinRequirements::Static(&[]), + target_strategy: BuiltinTargetStrategy::EirPrimitive, + target_support: BuiltinTargetSupport::All, + runtime_functions: BuiltinRuntimeFunctions::None, + argument_lowering: BuiltinArgumentLowering::Standard, + callable: BuiltinCallablePolicy::StaticOnly("internal compiler primitive"), + lowering: BuiltinLowering::Eir(lower), + } +} + /// Returns the conservative effect contract of a runtime type predicate. fn type_predicate_effects(_input: &BuiltinSemanticInput<'_>) -> Effects { Op::TypePredicate.default_effects() diff --git a/src/builtins/system/__elephc_class_has_constructor.rs b/src/builtins/system/__elephc_class_has_constructor.rs new file mode 100644 index 0000000000..52f502bef7 --- /dev/null +++ b/src/builtins/system/__elephc_class_has_constructor.rs @@ -0,0 +1,40 @@ +//! Purpose: +//! Declares the internal dynamic constructor-existence predicate used by PDO hydration. +//! +//! Called from: +//! - The generated PDO prelude after allocating and hydrating a `FETCH_CLASS` object. +//! +//! Key details: +//! - The result is derived from the AOT class table and includes inherited constructors. +//! - `internal: true` keeps this compiler primitive out of PHP-visible builtin catalogs. + +use crate::builtins::semantics::{ + internal_eir_semantics, BuiltinLoweringContext, BuiltinResultOwnership, + LoweredBuiltinValue, NormalizedBuiltinCall, +}; +use crate::ir::{Effects, Op}; + +builtin! { + name: "__elephc_class_has_constructor", + area: System, + params: [class: Str], + returns: Bool, + semantics: internal_eir_semantics(lower, Effects::PURE, BuiltinResultOwnership::NonHeap), + summary: "Reports whether a dynamically named AOT class has a constructor.", + internal: true +} + +/// Lowers the class-name predicate to the dedicated AOT metadata EIR primitive. +fn lower( + ctx: &mut dyn BuiltinLoweringContext, + call: &NormalizedBuiltinCall<'_>, +) -> Result { + Ok(ctx.emit_value( + Op::DynamicClassHasConstructor, + vec![call.operand(0)?], + None, + call.result_type.clone(), + Op::DynamicClassHasConstructor.default_effects(), + Some(call.span), + )) +} diff --git a/src/builtins/system/__elephc_initialize_pdo_statement.rs b/src/builtins/system/__elephc_initialize_pdo_statement.rs new file mode 100644 index 0000000000..718a8ee035 --- /dev/null +++ b/src/builtins/system/__elephc_initialize_pdo_statement.rs @@ -0,0 +1,44 @@ +//! Purpose: +//! Declares the internal PDOStatement native-state initializer used by `PDO::prepare()`. +//! +//! Called from: +//! - The generated PDO prelude after allocating the configured statement subclass. +//! +//! Key details: +//! - The lowering invokes PDOStatement's private initializer directly, so subclasses are +//! initialized before their user constructor without exposing a reset API to PHP code. + +use crate::builtins::semantics::{ + internal_eir_semantics, BuiltinLoweringContext, BuiltinResultOwnership, + LoweredBuiltinValue, NormalizedBuiltinCall, +}; +use crate::ir::{Effects, Op}; + +builtin! { + name: "__elephc_initialize_pdo_statement", + area: System, + params: [statement: Mixed, handle: Int, connection: Int, errorMode: Int, query: Str], + returns: Void, + semantics: internal_eir_semantics( + lower, + Effects::all().difference(Effects::REFCOUNT_OP), + BuiltinResultOwnership::NonHeap, + ), + summary: "Initializes a dynamically allocated PDOStatement subclass.", + internal: true +} + +/// Lowers private PDOStatement initialization to its direct-method EIR primitive. +fn lower( + ctx: &mut dyn BuiltinLoweringContext, + call: &NormalizedBuiltinCall<'_>, +) -> Result { + Ok(ctx.emit_value( + Op::DynamicPdoStatementInitialize, + call.operands.to_vec(), + None, + call.result_type.clone(), + Op::DynamicPdoStatementInitialize.default_effects(), + Some(call.span), + )) +} diff --git a/src/builtins/system/__elephc_invoke_pdo_statement_constructor.rs b/src/builtins/system/__elephc_invoke_pdo_statement_constructor.rs new file mode 100644 index 0000000000..4a0c2bbff6 --- /dev/null +++ b/src/builtins/system/__elephc_invoke_pdo_statement_constructor.rs @@ -0,0 +1,45 @@ +//! Purpose: +//! Declares the internal constructor invoker used for PDO custom statement classes. +//! +//! Called from: +//! - `PDO::prepare()` after native PDOStatement fields have been initialized. +//! +//! Key details: +//! - Dispatch uses AOT class metadata and deliberately bypasses userland visibility, matching +//! php-src's internal call of protected/private PDOStatement subclass constructors. +//! - Constructor arguments remain a boxed runtime container so named arguments are preserved. + +use crate::builtins::semantics::{ + internal_eir_semantics, BuiltinLoweringContext, BuiltinResultOwnership, + LoweredBuiltinValue, NormalizedBuiltinCall, +}; +use crate::ir::{Effects, Op}; + +builtin! { + name: "__elephc_invoke_pdo_statement_constructor", + area: System, + params: [class: Str, statement: Mixed, arguments: Mixed], + returns: Void, + semantics: internal_eir_semantics( + lower, + Effects::all().difference(Effects::REFCOUNT_OP), + BuiltinResultOwnership::NonHeap, + ), + summary: "Invokes a PDO statement subclass constructor after native initialization.", + internal: true +} + +/// Lowers internal PDO constructor invocation to its dynamic-dispatch EIR primitive. +fn lower( + ctx: &mut dyn BuiltinLoweringContext, + call: &NormalizedBuiltinCall<'_>, +) -> Result { + Ok(ctx.emit_value( + Op::DynamicPdoStatementConstructorCall, + call.operands.to_vec(), + None, + call.result_type.clone(), + Op::DynamicPdoStatementConstructorCall.default_effects(), + Some(call.span), + )) +} diff --git a/src/builtins/system/__elephc_new_without_constructor.rs b/src/builtins/system/__elephc_new_without_constructor.rs new file mode 100644 index 0000000000..82f2f5342c --- /dev/null +++ b/src/builtins/system/__elephc_new_without_constructor.rs @@ -0,0 +1,44 @@ +//! Purpose: +//! Declares the internal object-allocation builtin used by PDO fetch hydration. +//! +//! Called from: +//! - The generated PDO prelude when `FETCH_CLASS` uses PHP's default hydration order. +//! +//! Key details: +//! - Allocation initializes declared property defaults but deliberately does not invoke `__construct`. +//! - `internal: true` keeps this compiler primitive out of PHP-visible builtin catalogs. + +use crate::builtins::semantics::{ + internal_eir_semantics, BuiltinLoweringContext, BuiltinResultOwnership, + LoweredBuiltinValue, NormalizedBuiltinCall, +}; +use crate::ir::{Effects, Op}; + +builtin! { + name: "__elephc_new_without_constructor", + area: System, + params: [class: Str], + returns: Mixed, + semantics: internal_eir_semantics( + lower, + Effects::READS_HEAP.union(Effects::ALLOC_HEAP).union(Effects::MAY_DEOPT), + BuiltinResultOwnership::Fresh, + ), + summary: "Allocates a dynamically named object without invoking its constructor.", + internal: true +} + +/// Lowers constructorless allocation to the dedicated dynamic-object EIR primitive. +fn lower( + ctx: &mut dyn BuiltinLoweringContext, + call: &NormalizedBuiltinCall<'_>, +) -> Result { + Ok(ctx.emit_value( + Op::DynamicObjectNewWithoutConstructorMixed, + vec![call.operand(0)?], + None, + call.result_type.clone(), + Op::DynamicObjectNewWithoutConstructorMixed.default_effects(), + Some(call.span), + )) +} diff --git a/src/builtins/system/__elephc_pdo_called_class_status.rs b/src/builtins/system/__elephc_pdo_called_class_status.rs new file mode 100644 index 0000000000..b861008373 --- /dev/null +++ b/src/builtins/system/__elephc_pdo_called_class_status.rs @@ -0,0 +1,40 @@ +//! Purpose: +//! Declares the internal late-static PDO factory class classifier. +//! +//! Called from: +//! - `PDO::connect()` in the generated PHP 8.4+ PDO prelude. +//! +//! Key details: +//! - The result distinguishes base PDO, each driver hierarchy, and generic PDO subclasses. +//! - `internal: true` keeps this compiler primitive out of PHP-visible builtin catalogs. + +use crate::builtins::semantics::{ + internal_eir_semantics, BuiltinLoweringContext, BuiltinResultOwnership, + LoweredBuiltinValue, NormalizedBuiltinCall, +}; +use crate::ir::{Effects, Op}; + +builtin! { + name: "__elephc_pdo_called_class_status", + area: System, + params: [class: Str], + returns: Int, + semantics: internal_eir_semantics(lower, Effects::PURE, BuiltinResultOwnership::NonHeap), + summary: "Classifies PDO::connect's late-static called class by driver hierarchy.", + internal: true +} + +/// Lowers the late-static PDO classifier to the dedicated AOT metadata EIR primitive. +fn lower( + ctx: &mut dyn BuiltinLoweringContext, + call: &NormalizedBuiltinCall<'_>, +) -> Result { + Ok(ctx.emit_value( + Op::DynamicPdoCalledClassStatus, + vec![call.operand(0)?], + None, + call.result_type.clone(), + Op::DynamicPdoCalledClassStatus.default_effects(), + Some(call.span), + )) +} diff --git a/src/builtins/system/__elephc_pdo_statement_class_status.rs b/src/builtins/system/__elephc_pdo_statement_class_status.rs new file mode 100644 index 0000000000..1142f1001f --- /dev/null +++ b/src/builtins/system/__elephc_pdo_statement_class_status.rs @@ -0,0 +1,41 @@ +//! Purpose: +//! Declares the internal PDO statement-class validator used by the generated PDO prelude. +//! +//! Called from: +//! - `PDO::setAttribute()` and `PDO::prepare()` for `PDO::ATTR_STATEMENT_CLASS`. +//! +//! Key details: +//! - The integer result distinguishes unknown classes, wrong ancestry, public constructors, +//! concrete valid classes, and abstract valid classes from the AOT class table. +//! - `internal: true` keeps this compiler primitive out of PHP-visible builtin catalogs. + +use crate::builtins::semantics::{ + internal_eir_semantics, BuiltinLoweringContext, BuiltinResultOwnership, + LoweredBuiltinValue, NormalizedBuiltinCall, +}; +use crate::ir::{Effects, Op}; + +builtin! { + name: "__elephc_pdo_statement_class_status", + area: System, + params: [class: Str], + returns: Int, + semantics: internal_eir_semantics(lower, Effects::PURE, BuiltinResultOwnership::NonHeap), + summary: "Classifies a dynamically named class for PDO statement construction.", + internal: true +} + +/// Lowers PDO statement-class validation to the dedicated AOT metadata EIR primitive. +fn lower( + ctx: &mut dyn BuiltinLoweringContext, + call: &NormalizedBuiltinCall<'_>, +) -> Result { + Ok(ctx.emit_value( + Op::DynamicPdoStatementClassStatus, + vec![call.operand(0)?], + None, + call.result_type.clone(), + Op::DynamicPdoStatementClassStatus.default_effects(), + Some(call.span), + )) +} diff --git a/src/builtins/system/mod.rs b/src/builtins/system/mod.rs index a57b634c85..4460ce767f 100644 --- a/src/builtins/system/mod.rs +++ b/src/builtins/system/mod.rs @@ -25,8 +25,14 @@ //! - `json_support` holds shared helpers for the JSON/serialize check hooks. //! - Add `pub mod ;` here for every new system builtin home. +pub mod __elephc_class_has_constructor; pub mod __elephc_gmmktime_raw; +pub mod __elephc_initialize_pdo_statement; +pub mod __elephc_invoke_pdo_statement_constructor; pub mod __elephc_mktime_raw; +pub mod __elephc_new_without_constructor; +pub mod __elephc_pdo_called_class_status; +pub mod __elephc_pdo_statement_class_status; pub mod __elephc_strtotime_raw; pub mod attr_support; pub mod checkdate; diff --git a/src/cli.rs b/src/cli.rs index 7bdb5f8992..2684d42111 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -498,7 +498,7 @@ fn parse_required_php_version( if index < args.len() { parse_php_version(&args[index]) } else { - fail("Missing version after --php-version (expected 8.2, 8.3, 8.4, or 8.5)") + fail("Missing version after --php-version (expected 8.0 through 8.6)") } } @@ -506,8 +506,9 @@ fn parse_required_php_version( fn parse_php_version(value: &str) -> crate::web_prelude::PhpVersion { crate::web_prelude::PhpVersion::parse(value).unwrap_or_else(|| { fail(&format!( - "Unsupported PHP version '{}': expected 8.2, 8.3, 8.4, or 8.5", - value + "Unsupported PHP version '{}': expected one of: {}", + value, + crate::web_prelude::PhpVersion::accepted_values() )) }) } @@ -698,11 +699,13 @@ mod tests { /// Verifies every maintained PHP minor maps to its exact compatibility profile. #[test] fn maintained_php_versions_parse() { + assert_eq!(parse_php_version("8.0").version_id(), 80000); + assert_eq!(parse_php_version("8.1").version_id(), 80100); assert_eq!(parse_php_version("8.2").version_id(), 80200); assert_eq!(parse_php_version("8.3").version_id(), 80300); assert_eq!(parse_php_version("8.4").version_id(), 80400); assert_eq!(parse_php_version("8.5").version_id(), 80500); - assert!(crate::web_prelude::PhpVersion::parse("8.1").is_none()); + assert_eq!(parse_php_version("8.6").version_id(), 80600); } /// Verifies both CLI spellings store the selected PHP compatibility profile. diff --git a/src/codegen/context.rs b/src/codegen/context.rs index c690168b80..3865f797b2 100644 --- a/src/codegen/context.rs +++ b/src/codegen/context.rs @@ -242,6 +242,19 @@ impl<'a> FunctionContext<'a> { .ok_or_else(|| CodegenIrError::missing_entry("value", value.as_raw())) } + /// Returns a function value's IR storage type. + /// + /// This is the ONLY reliable way to tell whether a value is a genuinely boxed `Mixed` CELL. + /// The PHP type lies here: `Op::IChecked*` (which is what `$i++` lowers to) reports a PHP type + /// of `Mixed` while its runtime value is a RAW INTEGER, not a heap cell. Unboxing that as a + /// pointer reads garbage. + pub(super) fn value_ir_type(&self, value: ValueId) -> Result { + self.function + .value(value) + .map(|metadata| metadata.ir_type) + .ok_or_else(|| CodegenIrError::missing_entry("value", value.as_raw())) + } + /// Returns a function value's source PHP metadata before codegen representation erasure. pub(super) fn raw_value_php_type(&self, value: ValueId) -> Result { self.function diff --git a/src/codegen/frame.rs b/src/codegen/frame.rs index 2c93c38120..8ed6c48c1b 100644 --- a/src/codegen/frame.rs +++ b/src/codegen/frame.rs @@ -76,8 +76,18 @@ pub(super) fn layout_for_function( let mut local_offsets = HashMap::new(); let mut offset = value_placement.total_slot_bytes; for local in &function.locals { + // A parameter may be widened to Mixed storage after its signature has been fixed (for + // example, bindColumn() promotes a string parameter to a durable ref-cell). Reserve the + // larger of the local representation and the incoming ABI representation so saving a + // two-word string cannot overlap the preceding widened one-word local. + let incoming_param_bytes = function + .params + .get(local.id.as_raw() as usize) + .map(|param| param.php_type.codegen_repr().stack_size()) + .unwrap_or(0); let bytes = value_placement::bytes_for(local.ir_type) - .max(local.php_type.codegen_repr().stack_size()); + .max(local.php_type.codegen_repr().stack_size()) + .max(incoming_param_bytes); if bytes == 0 { continue; } @@ -101,6 +111,17 @@ pub(super) fn layout_for_function( offset += 8; callee_saved_offsets.push((*reg, offset)); } + // Method/callable dispatch hand-uses the reserved nested-call register + // (x19/r12) to hold a receiver across argument-lowering calls; it is + // callee-saved but outside the allocator's tracking, so reserve a save slot + // when the function needs it (issue #511). + if function_uses_nested_call_reg(function) { + let reg = nested_call_reg_name(target.arch); + if !callee_saved_offsets.iter().any(|(saved, _)| *saved == reg) { + offset += 8; + callee_saved_offsets.push((reg, offset)); + } + } offset += 8; let concat_base_offset = offset; let frame_size = align_to_16(offset + FRAME_FOOTER_BYTES); @@ -160,6 +181,47 @@ fn try_handler_tokens(function: &Function) -> Vec { tokens } +/// Returns the reserved "nested call" scratch register for `arch` — the +/// callee-saved register (`x19` / `r12`) that method- and callable-dispatch +/// lowering hand-uses to hold a receiver or descriptor across argument-lowering +/// calls. It lives outside the register allocator's pools, so a function that +/// uses it must reserve its own save slot (see `layout_for_function`). Mirrors +/// `abi::nested_call_reg`, which resolves the same register from an `Emitter`. +fn nested_call_reg_name(arch: Arch) -> &'static str { + match arch { + Arch::AArch64 => "x19", + Arch::X86_64 => "r12", + } +} + +/// Returns true when the function contains a method call whose receiver is +/// dispatched through the reserved nested-call register: a union receiver or a +/// receiver whose codegen representation is `Mixed`. `lower_mixed_method_call` +/// and `lower_nullable_receiver_method_call` hand-use `nested_call_reg` to hold +/// the unboxed object payload across argument-lowering calls, but that register +/// is callee-saved and outside the allocator's tracking — without a reserved +/// save slot the function silently clobbers the caller's value (issue #511: a +/// `--web` handler calling a method on a `PDOStatement|bool` receiver corrupted +/// the hyper worker's `x19`, freeing a garbage pointer during response flush). +/// A plain single non-nullable object receiver uses direct dispatch and is +/// excluded. Over-detection is harmless — an unused save/restore pair costs one +/// store and one load — so the receiver test errs toward inclusion. +fn function_uses_nested_call_reg(function: &Function) -> bool { + function.instructions.iter().any(|inst| { + if !matches!(inst.op, Op::MethodCall | Op::NullsafeMethodCall) { + return false; + } + let Some(receiver) = inst.operands.first() else { + return false; + }; + let Some(value) = function.value(*receiver) else { + return false; + }; + matches!(value.php_type, PhpType::Union(_)) + || matches!(value.php_type.codegen_repr(), PhpType::Mixed | PhpType::Union(_)) + }) +} + /// Emits the process-entry prologue for the EIR main function. pub(super) fn emit_main_prologue(ctx: &mut FunctionContext<'_>) { if ctx.emitter.target.arch == Arch::AArch64 { @@ -442,6 +504,13 @@ pub(super) fn emit_web_entry_stub(ctx: &mut FunctionContext<'_>) { ctx.emitter .comment("save argc/argv to globals for the bridge and handler"); abi::emit_store_process_args_to_globals(ctx.emitter); + // Enable the small-bin double-free guard for every --web worker process: a detected + // double free `_exit(1)`s the worker (the prefork master respawns it), containing + // corruption to one request. Cheap — a short bin-chain scan on free, with no + // per-allocation free-list validation. Set once here at worker startup, not per + // request. (A `--no-web-heap-guard` opt-out for benchmarking is a follow-up.) + ctx.emitter.comment("enable web heap-guard flag"); + abi::emit_enable_web_heap_guard_flag(ctx.emitter); let argc_reg = abi::int_arg_reg_name(target, 0); let argv_reg = abi::int_arg_reg_name(target, 1); let handler_reg = abi::int_arg_reg_name(target, 2); @@ -495,6 +564,11 @@ fn main_cleanup_locals(ctx: &FunctionContext<'_>) -> Vec<(String, LocalSlotId, P .locals .iter() .filter(|local| local_kind_needs_epilogue_cleanup(local.kind)) + // Slots this frame only BORROWS (inliner-transplanted callee params, and slots whose + // ownership moved into a return value) must never be released here: the frame never + // acquired them. Releasing a borrow is a use-after-free, and it is what made a + // read-only `array` param in a loop die with `heap debug detected bad refcount`. + .filter(|local| !ctx.function.no_epilogue_cleanup_slots.contains(&local.id)) .filter(|local| { !ctx.local_slot_ever_stores_ref_cell_pointer(local.id) || ctx.has_dynamic_ref_cell_state(local.id) @@ -834,6 +908,11 @@ fn function_cleanup_locals( .locals .iter() .filter(|local| local_kind_needs_epilogue_cleanup(local.kind)) + // Slots this frame only BORROWS (inliner-transplanted callee params, and slots whose + // ownership moved into a return value) must never be released here: the frame never + // acquired them. Releasing a borrow is a use-after-free, and it is what made a + // read-only `array` param in a loop die with `heap debug detected bad refcount`. + .filter(|local| !ctx.function.no_epilogue_cleanup_slots.contains(&local.id)) .filter(|local| { !ctx.local_slot_ever_stores_ref_cell_pointer(local.id) || ctx.has_dynamic_ref_cell_state(local.id) @@ -865,7 +944,7 @@ fn function_cleanup_locals( locals } -/// Returns whether a local slot is populated from the function's incoming parameter ABI. +/// Returns whether a local slot belongs to a function parameter. fn local_slot_is_parameter(function: &Function, slot: LocalSlotId) -> bool { function.params.get(slot.as_raw() as usize).is_some() } diff --git a/src/codegen/lower_inst.rs b/src/codegen/lower_inst.rs index 21dfb8eed1..f52f4b9511 100644 --- a/src/codegen/lower_inst.rs +++ b/src/codegen/lower_inst.rs @@ -211,6 +211,8 @@ pub(super) fn lower_instruction(ctx: &mut FunctionContext<'_>, inst_id: InstId) Op::StrToF => conversions::lower_str_to_float(ctx, &inst), Op::Cast => conversions::lower_cast(ctx, &inst), Op::MixedBox => lower_mixed_box(ctx, &inst), + Op::MixedClone => lower_mixed_clone(ctx, &inst), + Op::MixedUnbox => lower_mixed_unbox(ctx, &inst), Op::InvokerRefArg => lower_invoker_ref_arg(ctx, &inst), Op::ArrayToMixed => arrays::lower_array_to_mixed(ctx, &inst), Op::HashToMixed => hashes::lower_hash_to_mixed(ctx, &inst), @@ -226,6 +228,7 @@ pub(super) fn lower_instruction(ctx: &mut FunctionContext<'_>, inst_id: InstId) Op::ArrayIsset => builtins::lower_array_isset(ctx, &inst), Op::ArrayElemAddr => arrays::lower_array_elem_addr(ctx, &inst), Op::ArraySet => arrays::lower_array_set(ctx, &inst), + Op::SlotDetach => arrays::lower_slot_detach(ctx, &inst), Op::ArraySetMixedKey => arrays::lower_array_set_mixed_key(ctx, &inst), Op::ArrayGetMixedKey => arrays::lower_array_get_mixed_key(ctx, &inst, true), Op::ArrayGetMixedKeySilent => arrays::lower_array_get_mixed_key(ctx, &inst, false), @@ -262,6 +265,24 @@ pub(super) fn lower_instruction(ctx: &mut FunctionContext<'_>, inst_id: InstId) Op::DynamicObjectNewWithoutConstructorMixed => { objects::lower_dynamic_object_new_without_constructor_mixed(ctx, &inst) } + Op::CallablePtr => builtins::pointers::lower_elephc_callable_ptr(ctx, &inst), + Op::NormalizeCallable => builtins::pointers::lower_elephc_normalize_callable(ctx, &inst), + Op::PdoAdapterAddr => builtins::pointers::lower_elephc_pdo_adapter_addr(ctx, &inst), + Op::DynamicClassHasConstructor => { + builtins::system::lower_elephc_class_has_constructor(ctx, &inst) + } + Op::DynamicPdoStatementClassStatus => { + builtins::system::lower_elephc_pdo_statement_class_status(ctx, &inst) + } + Op::DynamicPdoCalledClassStatus => { + builtins::system::lower_elephc_pdo_called_class_status(ctx, &inst) + } + Op::DynamicPdoStatementConstructorCall => { + builtins::system::lower_elephc_invoke_pdo_statement_constructor(ctx, &inst) + } + Op::DynamicPdoStatementInitialize => { + builtins::system::lower_elephc_initialize_pdo_statement(ctx, &inst) + } Op::PropGet => objects::lower_prop_get(ctx, &inst), Op::PropInitialized => objects::lower_prop_initialized(ctx, &inst), Op::LoadPropRefCell => objects::lower_load_prop_ref_cell(ctx, &inst), @@ -336,6 +357,7 @@ pub(super) fn lower_instruction(ctx: &mut FunctionContext<'_>, inst_id: InstId) Op::FunctionVariantDispatch => Ok(()), Op::FunctionVariantMark => lower_function_variant_mark(ctx, &inst), Op::RuntimeCall => lower_runtime_call(ctx, &inst), + Op::MixedArrayGetForWrite => lower_mixed_array_runtime_get(ctx, &inst, true), Op::GeneratorYield => lower_generator_yield(ctx, &inst), Op::GeneratorYieldFrom => lower_generator_yield_from(ctx, &inst), Op::ConcatReset => lower_concat_reset(ctx), diff --git a/src/codegen/lower_inst/array_access_runtime.rs b/src/codegen/lower_inst/array_access_runtime.rs index 97bdce9978..53507c6cde 100644 --- a/src/codegen/lower_inst/array_access_runtime.rs +++ b/src/codegen/lower_inst/array_access_runtime.rs @@ -22,7 +22,7 @@ pub(super) fn lower_runtime_call(ctx: &mut FunctionContext<'_>, inst: &Instructi } if inst.operands.len() == 3 { if inst.result_php_type.codegen_repr() != PhpType::Void { - return lower_mixed_array_runtime_get(ctx, inst); + return lower_mixed_array_runtime_get(ctx, inst, false); } return lower_mixed_array_runtime_set(ctx, inst); } @@ -436,4 +436,3 @@ pub(super) fn lower_mixed_to_mixed_assoc_array(ctx: &mut FunctionContext<'_>) -> ); Ok(()) } - diff --git a/src/codegen/lower_inst/arrays.rs b/src/codegen/lower_inst/arrays.rs index 5f3b662944..0a58ab004f 100644 --- a/src/codegen/lower_inst/arrays.rs +++ b/src/codegen/lower_inst/arrays.rs @@ -250,6 +250,9 @@ enum ArrayGetMode { /// Runtime COW helper matching the element's container kind. helper: &'static str, }, + /// Nested-write fetch for a boxed Mixed slot: retain the owning cell so the later write can + /// publish a replacement back into the parent container. + MixedForWrite, } /// Lowers an indexed-array element read with PHP null-sentinel fallback on misses. @@ -279,14 +282,19 @@ pub(super) fn lower_array_get( /// iterating it by reference. What reaches the loop is unique, so `iter_start`'s own /// `ensure_unique` is a no-op and the writes land in the container the parent holds. /// -/// Not to be confused with `RuntimeCallTarget::ArrayFetchForWrite`, which serves the same -/// purpose for boxed `Mixed` containers (issue #555); this is the statically-typed indexed path. +/// Boxed `Mixed` elements use the nested-write ownership contract from issue #555 instead: the +/// stored cell is retained and returned so later write-back can publish any replacement into the +/// parent. Statically typed array/hash elements use the copy-on-write path from issue #580. pub(super) fn lower_array_get_for_write( ctx: &mut FunctionContext<'_>, inst: &Instruction, ) -> Result<()> { let array = expect_operand(inst, 0)?; let elem_ty = indexed_array_element_type(&ctx.value_php_type(array)?, inst)?; + require_array_get_result(&elem_ty, inst)?; + if matches!(inst.result_php_type.codegen_repr(), PhpType::Mixed) { + return lower_array_get_in_mode(ctx, inst, true, ArrayGetMode::MixedForWrite); + } let helper = array_get_for_write_cow_helper(&elem_ty).ok_or_else(|| { CodegenIrError::unsupported(format!( "array_get_for_write element PHP type {:?}", @@ -358,6 +366,16 @@ fn lower_array_get_in_mode( let elem_ty = indexed_array_element_type(&ctx.value_php_type(array)?, inst)?; require_array_get_result(&elem_ty, inst)?; let result_ty = inst.result_php_type.codegen_repr(); + if matches!(result_ty, PhpType::Mixed) { + return lower_array_get_runtime_mixed( + ctx, + inst, + array, + index, + warn_on_missing, + matches!(mode, ArrayGetMode::MixedForWrite), + ); + } match ctx.emitter.target.arch { Arch::AArch64 => lower_array_get_aarch64( ctx, @@ -605,20 +623,35 @@ pub(super) fn lower_array_get_mixed_key( key_ty ))); } + lower_array_get_runtime_mixed(ctx, inst, array, key, warn_on_missing, false) +} + +/// Reads an indexed-or-promoted array through its runtime storage metadata and returns a fresh +/// boxed Mixed cell, preserving typed slots when control-flow has widened only the static type. +fn lower_array_get_runtime_mixed( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, + array: ValueId, + key: ValueId, + warn_on_missing: bool, + for_write: bool, +) -> Result<()> { match ctx.emitter.target.arch { Arch::AArch64 => { super::hashes::materialize_hash_key_aarch64(ctx, key)?; abi::emit_push_reg_pair(ctx.emitter, "x1", "x2"); ctx.load_value_to_reg(array, "x0")?; abi::emit_pop_reg_pair(ctx.emitter, "x1", "x2"); - abi::emit_load_int_immediate(ctx.emitter, "x3", if warn_on_missing { 1 } else { 0 }); + let flags = i64::from(warn_on_missing) | (i64::from(for_write) << 1); + abi::emit_load_int_immediate(ctx.emitter, "x3", flags); } Arch::X86_64 => { super::hashes::materialize_hash_key_x86_64(ctx, key)?; abi::emit_push_reg_pair(ctx.emitter, "rsi", "rdx"); ctx.load_value_to_reg(array, "rdi")?; abi::emit_pop_reg_pair(ctx.emitter, "rsi", "rdx"); - abi::emit_load_int_immediate(ctx.emitter, "rcx", if warn_on_missing { 1 } else { 0 }); + let flags = i64::from(warn_on_missing) | (i64::from(for_write) << 1); + abi::emit_load_int_immediate(ctx.emitter, "rcx", flags); } } abi::emit_call_label(ctx.emitter, "__rt_array_get_mixed_key"); @@ -641,6 +674,12 @@ fn lower_array_set_mixed_key_aarch64( } abi::emit_push_reg(ctx.emitter, "x0"); ctx.load_value_to_reg(array, "x0")?; + // Hand the helper an OWNED reference, mirroring `Op::ArrayToHash`. Its promote paths abandon + // the source indexed array for a freshly built hash and must release it; without this acquire + // the helper would be releasing the caller's only reference — a use-after-free. With it, the + // ledger closes: the in-place paths hand the `+1` back inside the returned pointer, the promote + // paths consume it, and `store_local` then releases whatever the slot held before. + abi::emit_incref_if_refcounted(ctx.emitter, &ctx.value_php_type(array)?); ctx.load_value_to_reg(key, "x1")?; abi::emit_pop_reg(ctx.emitter, "x2"); abi::emit_call_label(ctx.emitter, "__rt_array_set_mixed_key"); @@ -662,7 +701,12 @@ fn lower_array_set_mixed_key_x86_64( box_value_for_mixed_container(ctx, value, value_ty)?; } abi::emit_push_reg(ctx.emitter, "rax"); - ctx.load_value_to_reg(array, "rdi")?; + ctx.load_value_to_reg(array, "rax")?; + // See the AArch64 twin: the helper is handed an OWNED reference so its promote paths can + // release the source array they abandon. `emit_incref_if_refcounted` retains the pointer in the + // int-result register, so the array is loaded there first and moved into the ABI register after. + abi::emit_incref_if_refcounted(ctx.emitter, &ctx.value_php_type(array)?); + ctx.emitter.instruction("mov rdi, rax"); // publish the retained array as the helper's first argument ctx.load_value_to_reg(key, "rsi")?; abi::emit_pop_reg(ctx.emitter, "rdx"); abi::emit_call_label(ctx.emitter, "__rt_array_set_mixed_key"); @@ -779,20 +823,61 @@ fn lower_array_get_aarch64( let array_reg = abi::symbol_scratch_reg(ctx.emitter); let len_reg = abi::secondary_scratch_reg(ctx.emitter); let result_reg = abi::int_result_reg(ctx.emitter); - ctx.load_value_to_reg(index, result_reg)?; - ctx.load_value_to_reg(array, array_reg)?; let null_label = ctx.next_label("array_get_null"); let null_receiver_label = ctx.next_label("array_get_null_recv"); let fallback_label = ctx.next_label("array_get_fallback"); let done_label = ctx.next_label("array_get_done"); - - // -- guard the receiver: a missed outer read carries a null/sentinel container -- + let promoted_label = ctx.next_label("array_get_promoted"); + + // An `Array(_)`-typed local can be backed by HASH storage at runtime: a mixed-key write + // promotes the storage kind while the checker only promotes the STATIC type to `AssocArray` at + // a provably string-keyed write. The packed payload walk below is only valid on kind-2 storage + // — on a hash it bounds-checks the index against the header's live-entry count and then reads + // the header's own fields as if they were elements, which SEGFAULTED. Dispatch on the runtime + // kind, exactly as the `isset` probe and `__rt_array_get_mixed_key` already do. + // An array with no element type has no elements to read, and the hash-value materializer has no + // representation to produce for `Void`/`Never`, so such a receiver keeps the packed-only path: + // every read of it is a miss either way. + let elem_is_empty = matches!(elem_ty.codegen_repr(), PhpType::Void | PhpType::Never); + // The promoted read is emitted speculatively, so it must be *representable* for every element + // type it is emitted for — an unsupported one fails the compile instead of sitting unreached. + // `?int` (`TaggedScalar`) has no hash representation on either side of the lookup, so an array + // of them can never be hash-backed and the packed-only path stays correct. + let can_read_promoted = !elem_is_empty && super::hashes::hash_get_supports_value_type(elem_ty); + // Gate on the IR type, NOT the PHP type: `Op::IChecked*` — what `$i++` lowers to — reports a + // PHP type of `Mixed` while its runtime value is a RAW INTEGER. Unboxing that as a cell pointer + // reads garbage, and every read through an incremented loop counter silently returned nothing. + let index_is_mixed_key = matches!( + ctx.value_ir_type(index)?, + crate::ir::IrType::Heap(crate::ir::IrHeapKind::Mixed) + ); + ctx.load_value_to_reg(array, array_reg)?; + // -- guard the receiver before reading its storage-kind metadata -- crate::codegen::sentinels::emit_branch_if_null_container( ctx.emitter, array_reg, len_reg, &null_receiver_label, ); + if can_read_promoted { + ctx.emitter.instruction(&format!("ldr {}, [{}, #-8]", len_reg, array_reg)); // load the storage-kind metadata word from the array header + ctx.emitter.instruction(&format!("and {}, {}, #0xff", len_reg, len_reg)); // isolate the low byte holding the storage kind + ctx.emitter.instruction(&format!("cmp {}, #3", len_reg)); // kind 3 = storage was promoted to a hash at runtime + ctx.emitter.instruction(&format!("b.eq {}", promoted_label)); // a promoted array has no packed payload to index into + } + + // A `Mixed` key arrives as a boxed cell, not an integer. Materialize it into the normalized + // (key_lo, key_hi) pair — which also applies PHP's numeric-string rule — and reject a genuine + // string key outright: packed storage never holds one. + if index_is_mixed_key { + super::hashes::materialize_hash_key_aarch64(ctx, index)?; + ctx.emitter.instruction("cmn x2, #1"); // key_hi == -1 marks an integer key + ctx.emitter.instruction(&format!("b.ne {}", null_label)); // a string key never exists in packed storage + ctx.emitter.instruction(&format!("mov {}, x1", result_reg)); // adopt the normalized integer key as the offset + } else { + ctx.load_value_to_reg(index, result_reg)?; + } + ctx.load_value_to_reg(array, array_reg)?; ctx.emitter.instruction(&format!("cmp {}, #0", result_reg)); // check whether the indexed-array offset is negative ctx.emitter.instruction(&format!("b.lt {}", null_label)); // negative indexed-array offsets read as null abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); @@ -800,6 +885,19 @@ fn lower_array_get_aarch64( ctx.emitter.instruction(&format!("b.ge {}", null_label)); // out-of-range indexed-array offsets read as null emit_array_get_in_bounds_aarch64(ctx, array_reg, result_reg, elem_ty, result_ty, mode)?; ctx.emitter.instruction(&format!("b {}", done_label)); // skip the null fallback after a successful indexed-array read + + // -- promoted to hash storage: read through the hash, materializing the SAME representation + // the packed path produces, so the op's result type is unchanged -- + if can_read_promoted { + ctx.emitter.label(&promoted_label); + super::hashes::materialize_hash_key_aarch64(ctx, index)?; + ctx.load_value_to_reg(array, "x0")?; + abi::emit_call_label(ctx.emitter, "__rt_hash_get"); + ctx.emitter.instruction(&format!("cbz x0, {}", null_label)); // a missing key falls through to the shared null/warning path + super::hashes::emit_hash_get_success_aarch64(ctx, elem_ty, result_ty, false)?; + ctx.emitter.instruction(&format!("b {}", done_label)); // skip the null fallback after a promoted-hash read + } + ctx.emitter.label(&null_label); if warn_on_missing { emit_undefined_array_key_warning(ctx); @@ -874,20 +972,53 @@ fn lower_array_get_x86_64( let array_reg = abi::symbol_scratch_reg(ctx.emitter); let len_reg = abi::secondary_scratch_reg(ctx.emitter); let result_reg = abi::int_result_reg(ctx.emitter); - ctx.load_value_to_reg(array, array_reg)?; - ctx.load_value_to_reg(index, result_reg)?; let null_label = ctx.next_label("array_get_null"); let null_receiver_label = ctx.next_label("array_get_null_recv"); let fallback_label = ctx.next_label("array_get_fallback"); let done_label = ctx.next_label("array_get_done"); - - // -- guard the receiver: a missed outer read carries a null/sentinel container -- + let promoted_label = ctx.next_label("array_get_promoted"); + + // Storage-kind guarded exactly like the AArch64 twin: an `Array(_)`-typed local can be + // hash-backed at runtime, and walking its packed payload then reads the hash header's own + // fields as elements. + // See the AArch64 twin: an array with no element type keeps the packed-only path. + let elem_is_empty = matches!(elem_ty.codegen_repr(), PhpType::Void | PhpType::Never); + // See the AArch64 twin: the promoted read is emitted speculatively, so an element type the hash + // path cannot materialize (`?int` — `TaggedScalar`) fails the compile rather than sitting + // unreached. Such an array can never be hash-backed, so the packed-only path stays correct. + let can_read_promoted = !elem_is_empty && super::hashes::hash_get_supports_value_type(elem_ty); + // Gate on the IR type, NOT the PHP type: `Op::IChecked*` — what `$i++` lowers to — reports a + // PHP type of `Mixed` while its runtime value is a RAW INTEGER. Unboxing that as a cell pointer + // reads garbage, and every read through an incremented loop counter silently returned nothing. + let index_is_mixed_key = matches!( + ctx.value_ir_type(index)?, + crate::ir::IrType::Heap(crate::ir::IrHeapKind::Mixed) + ); + ctx.load_value_to_reg(array, array_reg)?; + // -- guard the receiver before reading its storage-kind metadata -- crate::codegen::sentinels::emit_branch_if_null_container( ctx.emitter, array_reg, len_reg, &null_receiver_label, ); + if can_read_promoted { + ctx.emitter.instruction(&format!("mov {}, QWORD PTR [{} - 8]", len_reg, array_reg)); // load the storage-kind metadata word from the array header + ctx.emitter.instruction(&format!("and {}, 0xff", len_reg)); // isolate the low byte holding the storage kind + ctx.emitter.instruction(&format!("cmp {}, 3", len_reg)); // kind 3 = storage was promoted to a hash at runtime + ctx.emitter.instruction(&format!("je {}", promoted_label)); // a promoted array has no packed payload to index into + } + + // See the AArch64 twin: a `Mixed` key is materialized, not loaded as an integer. + if index_is_mixed_key { + super::hashes::materialize_hash_key_x86_64(ctx, index)?; + ctx.emitter.instruction("cmp rdx, -1"); // key_hi == -1 marks an integer key + ctx.emitter.instruction(&format!("jne {}", null_label)); // a string key never exists in packed storage + ctx.emitter.instruction(&format!("mov {}, rsi", result_reg)); // adopt the normalized integer key as the offset + } else { + ctx.load_value_to_reg(index, result_reg)?; + } + ctx.load_value_to_reg(array, array_reg)?; ctx.emitter.instruction(&format!("cmp {}, 0", result_reg)); // check whether the indexed-array offset is negative ctx.emitter.instruction(&format!("jl {}", null_label)); // negative indexed-array offsets read as null abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); @@ -895,6 +1026,20 @@ fn lower_array_get_x86_64( ctx.emitter.instruction(&format!("jge {}", null_label)); // out-of-range indexed-array offsets read as null emit_array_get_in_bounds_x86_64(ctx, array_reg, result_reg, elem_ty, result_ty, mode)?; ctx.emitter.instruction(&format!("jmp {}", done_label)); // skip the null fallback after a successful indexed-array read + + // -- promoted to hash storage: read through the hash, materializing the SAME representation + // the packed path produces, so the op's result type is unchanged -- + if can_read_promoted { + ctx.emitter.label(&promoted_label); + super::hashes::materialize_hash_key_x86_64(ctx, index)?; + ctx.load_value_to_reg(array, "rdi")?; + abi::emit_call_label(ctx.emitter, "__rt_hash_get"); + ctx.emitter.instruction("test rax, rax"); // did the promoted hash storage hold the key? + ctx.emitter.instruction(&format!("jz {}", null_label)); // a missing key falls through to the shared null/warning path + super::hashes::emit_hash_get_success_x86_64(ctx, elem_ty, result_ty, false)?; + ctx.emitter.instruction(&format!("jmp {}", done_label)); // skip the null fallback after a promoted-hash read + } + ctx.emitter.label(&null_label); if warn_on_missing { emit_undefined_array_key_warning(ctx); @@ -1134,7 +1279,7 @@ fn emit_array_get_for_write_in_bounds_x86_64( abi::emit_store_to_address(ctx.emitter, result_reg, array_reg, 0); } -/// Dereferences descriptor-style ref-cell markers loaded from Mixed array slots. +/// Copies a loaded Mixed slot into a fresh zval cell, dereferencing ref-cell markers first. fn emit_mixed_array_get_deref_invoker_ref_cell( ctx: &mut FunctionContext<'_>, mixed_reg: &str, @@ -1155,7 +1300,11 @@ fn emit_mixed_array_get_deref_invoker_ref_cell( } abi::emit_load_from_address(ctx.emitter, tag_reg, mixed_reg, 0); emit_branch_if_invoker_ref_cell_tag(ctx, tag_reg, &ref_label); - abi::emit_incref_if_refcounted(ctx.emitter, &PhpType::Mixed); + abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); + if ctx.emitter.target.arch == Arch::X86_64 { + ctx.emitter.instruction("mov rsi, rdx"); // adapt the unboxed high payload word to the boxing helper ABI + } + abi::emit_call_label(ctx.emitter, "__rt_mixed_from_value"); abi::emit_jump(ctx.emitter, &done_label); ctx.emitter.label(&ref_label); @@ -1210,7 +1359,11 @@ fn emit_box_loaded_invoker_ref_cell_value_as_mixed( ctx.emitter.label(&mixed_cell_label); let result_reg = abi::int_result_reg(ctx.emitter); abi::emit_reg_move(ctx.emitter, result_reg, lo_reg); - abi::emit_incref_if_refcounted(ctx.emitter, &PhpType::Mixed); + abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); + if ctx.emitter.target.arch == Arch::X86_64 { + ctx.emitter.instruction("mov rsi, rdx"); // adapt the unboxed high payload word to the boxing helper ABI + } + abi::emit_call_label(ctx.emitter, "__rt_mixed_from_value"); ctx.emitter.label(&done_label); } @@ -2285,3 +2438,116 @@ fn expect_capacity(inst: &Instruction) -> Result { ))), } } + +/// Lowers `Op::SlotDetach` — writes PHP null into `container[key]`, releasing whatever was there. +/// +/// This is the nested-append lowering's ownership hand-off. After the bucket has been read into +/// the append temporary, it is owned twice (the container slot and the temporary), which would +/// make the upcoming push copy-on-write clone it. Nulling the slot drops the count back to one, +/// so the push mutates in place; the write-back that follows re-publishes the bucket into the +/// same slot. It cannot free the bucket: it only ever runs after the read has taken its +/// reference, so the count it decrements is at least two. +/// +/// No new runtime helper and no new ABI knowledge: the two storage kinds go through the very +/// call sites `lower_hash_set` and `lower_array_set` already use, with a null payload +/// substituted for the value. `__rt_hash_set` may grow or rehash the table, and +/// `__rt_array_set_refcounted` may copy-on-write split the array, so both return a possibly-new +/// container pointer — which the receiver write-back below republishes. +pub(super) fn lower_slot_detach(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { + let container = expect_operand(inst, 0)?; + let key = expect_operand(inst, 1)?; + let container_ty = ctx.value_php_type(container)?.codegen_repr(); + let source_local = source_load_local_slot(ctx, container)?; + if let Some(slot) = source_local { + ctx.release_mutated_source_local_owner(slot, container)?; + } + match container_ty { + PhpType::AssocArray { .. } => match ctx.emitter.target.arch { + Arch::AArch64 => lower_slot_detach_hash_aarch64(ctx, container, key)?, + Arch::X86_64 => lower_slot_detach_hash_x86_64(ctx, container, key)?, + }, + PhpType::Array(_) => match ctx.emitter.target.arch { + Arch::AArch64 => lower_slot_detach_indexed_aarch64(ctx, container, key)?, + Arch::X86_64 => lower_slot_detach_indexed_x86_64(ctx, container, key)?, + }, + other => { + return Err(CodegenIrError::unsupported(format!( + "slot_detach container PHP type {:?}", + other + ))); + } + } + ctx.store_result_value(container)?; + if let Some(slot) = source_local { + ctx.store_value_to_local(slot, container)?; + } + ctx.writeback_global_array_source(container)?; + Ok(()) +} + +/// Emits the AArch64 hash-storage slot detach: `__rt_hash_set(table, key, null)`. +/// +/// Mirrors `lower_hash_set_aarch64`'s call sequence exactly, minus the value materialization: +/// PHP null is the `(value_lo = 0, value_hi = 0, value_tag = 8)` triple. The key is +/// materialized before the table is loaded because key normalization may call a helper, which +/// would clobber `x0`. +fn lower_slot_detach_hash_aarch64( + ctx: &mut FunctionContext<'_>, + hash: ValueId, + key: ValueId, +) -> Result<()> { + super::hashes::materialize_hash_key_aarch64(ctx, key)?; + ctx.load_value_to_reg(hash, "x0")?; + ctx.emitter.instruction("mov x3, xzr"); // value_lo = 0 (PHP null has no payload) + ctx.emitter.instruction("mov x4, xzr"); // value_hi = 0 + abi::emit_load_int_immediate(ctx.emitter, "x5", 8); + abi::emit_call_label(ctx.emitter, "__rt_hash_set"); + Ok(()) +} + +/// Emits the x86_64 hash-storage slot detach: `__rt_hash_set(table, key, null)`. +/// +/// `__rt_hash_set`'s SysV ABI is `rdi = table, rsi = key_lo, rdx = key_hi, rcx = value_lo, +/// r8 = value_hi, r9 = value_tag -> rax = table`. +fn lower_slot_detach_hash_x86_64( + ctx: &mut FunctionContext<'_>, + hash: ValueId, + key: ValueId, +) -> Result<()> { + super::hashes::materialize_hash_key_x86_64(ctx, key)?; + ctx.load_value_to_reg(hash, "rdi")?; + ctx.emitter.instruction("xor ecx, ecx"); // value_lo = 0 (PHP null has no payload) + ctx.emitter.instruction("xor r8d, r8d"); // value_hi = 0 + abi::emit_load_int_immediate(ctx.emitter, "r9", 8); + abi::emit_call_label(ctx.emitter, "__rt_hash_set"); + Ok(()) +} + +/// Emits the AArch64 indexed-storage slot detach: `__rt_array_set_refcounted(array, index, 0)`. +/// +/// A null payload makes the helper skip its element-type stamp and its retain (an incref of a +/// null pointer is a no-op), while still releasing the element it overwrites. +fn lower_slot_detach_indexed_aarch64( + ctx: &mut FunctionContext<'_>, + array: ValueId, + index: ValueId, +) -> Result<()> { + ctx.load_value_to_reg(array, "x0")?; + ctx.load_value_to_reg(index, "x1")?; + ctx.emitter.instruction("mov x2, xzr"); // payload = null: release the old element, store nothing + abi::emit_call_label(ctx.emitter, "__rt_array_set_refcounted"); + Ok(()) +} + +/// Emits the x86_64 indexed-storage slot detach: `__rt_array_set_refcounted(array, index, 0)`. +fn lower_slot_detach_indexed_x86_64( + ctx: &mut FunctionContext<'_>, + array: ValueId, + index: ValueId, +) -> Result<()> { + ctx.load_value_to_reg(array, "rdi")?; + ctx.load_value_to_reg(index, "rsi")?; + ctx.emitter.instruction("xor edx, edx"); // payload = null: release the old element, store nothing + abi::emit_call_label(ctx.emitter, "__rt_array_set_refcounted"); + Ok(()) +} diff --git a/src/codegen/lower_inst/builtins/arrays/key_exists.rs b/src/codegen/lower_inst/builtins/arrays/key_exists.rs index a72e1907b5..6078c65b07 100644 --- a/src/codegen/lower_inst/builtins/arrays/key_exists.rs +++ b/src/codegen/lower_inst/builtins/arrays/key_exists.rs @@ -6,8 +6,19 @@ //! - `crate::codegen::lower_inst::builtins::arrays::lower_array_key_exists()`. //! //! Key details: -//! - Indexed arrays use `__rt_array_key_exists` with integer-like keys. +//! - Indexed arrays use `__rt_array_key_exists` with integer-like keys, and +//! `__rt_array_key_exists_mixed_key` (the storage-kind-dispatching presence +//! probe, mirroring `__rt_array_get_mixed_key`'s packed/hash dispatch) for a +//! Str/Mixed/Union/null key — an `Array(_)`-typed local can still be +//! runtime-backed by promoted hash storage even though the checker only +//! promotes the *static* type to `AssocArray` at a provably string-keyed write. //! - Associative arrays probe `__rt_hash_get`; its found flag is already a PHP bool result. +//! - Boxed Mixed/Union arrays unbox at runtime and dispatch tags 4/5 to the same packed/hash +//! probes, which preserves key presence after flow checks such as `is_array()`. +//! - `array_key_exists()` must answer `true` for a key present with a `null` +//! value (unlike `isset()`, which answers `false`), so the mixed-key path +//! cannot reuse `__rt_array_get_mixed_key` plus an is-null check — it needs +//! its own presence-only helper. use crate::codegen::abi; use crate::codegen::platform::Arch; @@ -138,7 +149,26 @@ fn lower_indexed_array_key_exists( key: ValueId, array: ValueId, ) -> Result<()> { - require_indexed_key_type(ctx.value_php_type(key)?)?; + match ctx.value_php_type(key)?.codegen_repr() { + PhpType::Int | PhpType::Bool => lower_indexed_array_key_exists_int(ctx, inst, key, array), + PhpType::Str | PhpType::Mixed | PhpType::Union(_) | PhpType::Void | PhpType::Never => { + lower_indexed_array_key_exists_mixed_key(ctx, inst, key, array) + } + other => Err(CodegenIrError::unsupported(format!( + "array_key_exists key PHP type {:?}", + other + ))), + } +} + +/// Lowers indexed-array key existence for an Int/Bool key through the +/// bounds-check runtime helper. +fn lower_indexed_array_key_exists_int( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, + key: ValueId, + array: ValueId, +) -> Result<()> { match ctx.emitter.target.arch { Arch::AArch64 => { ctx.load_value_to_reg(array, "x0")?; @@ -153,6 +183,34 @@ fn lower_indexed_array_key_exists( store_if_result(ctx, inst) } +/// Lowers indexed-array key existence for a Str/Mixed/Union/null key through +/// `__rt_array_key_exists_mixed_key`, which dispatches on the array's runtime +/// storage kind (packed vs. promoted-to-hash) exactly like +/// `__rt_array_get_mixed_key`'s read path, but only reports presence. +fn lower_indexed_array_key_exists_mixed_key( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, + key: ValueId, + array: ValueId, +) -> Result<()> { + match ctx.emitter.target.arch { + Arch::AArch64 => { + super::super::super::hashes::materialize_hash_key_aarch64(ctx, key)?; + abi::emit_push_reg_pair(ctx.emitter, "x1", "x2"); + ctx.load_value_to_reg(array, "x0")?; + abi::emit_pop_reg_pair(ctx.emitter, "x1", "x2"); + } + Arch::X86_64 => { + super::super::super::hashes::materialize_hash_key_x86_64(ctx, key)?; + abi::emit_push_reg_pair(ctx.emitter, "rsi", "rdx"); + ctx.load_value_to_reg(array, "rdi")?; + abi::emit_pop_reg_pair(ctx.emitter, "rsi", "rdx"); + } + } + abi::emit_call_label(ctx.emitter, "__rt_array_key_exists_mixed_key"); + store_if_result(ctx, inst) +} + /// Lowers associative-array key existence by probing the hash table. fn lower_assoc_array_key_exists( ctx: &mut FunctionContext<'_>, @@ -318,14 +376,3 @@ fn materialize_mixed_hash_key_x86_64( ctx.emitter.label(&done); Ok(()) } - -/// Verifies indexed-array key existence can use the integer-key runtime helper. -fn require_indexed_key_type(key_ty: PhpType) -> Result<()> { - match key_ty.codegen_repr() { - PhpType::Int | PhpType::Bool => Ok(()), - other => Err(CodegenIrError::unsupported(format!( - "array_key_exists key PHP type {:?}", - other - ))), - } -} diff --git a/src/codegen/lower_inst/builtins/arrays/keys.rs b/src/codegen/lower_inst/builtins/arrays/keys.rs index 50103e750f..4d897ed87b 100644 --- a/src/codegen/lower_inst/builtins/arrays/keys.rs +++ b/src/codegen/lower_inst/builtins/arrays/keys.rs @@ -7,7 +7,7 @@ //! //! Key details: //! - Associative keys are collected in insertion order through `__rt_hash_iter_next`. -//! - String keys are persisted before storing them in the result indexed array. +//! - String keys are copied out of hash-owned storage before entering the result array. //! - Mixed key arrays box each normalized int/string key into an owned Mixed cell. use crate::codegen::abi; @@ -522,8 +522,8 @@ fn emit_assoc_mixed_key_append_aarch64(ctx: &mut FunctionContext<'_>, key_ty: &P emit_append_word_key_aarch64(ctx, "x0"); } PhpType::Str => { - ctx.emitter.instruction("mov x0, #1"); // runtime tag 1 = string mixed key - abi::emit_call_label(ctx.emitter, "__rt_mixed_from_value"); + abi::emit_call_label(ctx.emitter, "__rt_str_persist"); + crate::codegen::emit_box_current_owned_value_as_mixed(ctx.emitter, &PhpType::Str); emit_append_word_key_aarch64(ctx, "x0"); } PhpType::Mixed => { @@ -536,8 +536,8 @@ fn emit_assoc_mixed_key_append_aarch64(ctx: &mut FunctionContext<'_>, key_ty: &P abi::emit_call_label(ctx.emitter, "__rt_mixed_from_value"); ctx.emitter.instruction(&format!("b {}", key_boxed)); // skip string-key boxing after producing an integer mixed key ctx.emitter.label(&key_string); - ctx.emitter.instruction("mov x0, #1"); // runtime tag 1 = string mixed key - abi::emit_call_label(ctx.emitter, "__rt_mixed_from_value"); + abi::emit_call_label(ctx.emitter, "__rt_str_persist"); + crate::codegen::emit_box_current_owned_value_as_mixed(ctx.emitter, &PhpType::Str); ctx.emitter.label(&key_boxed); emit_append_word_key_aarch64(ctx, "x0"); } @@ -561,9 +561,9 @@ fn emit_assoc_mixed_key_append_x86_64(ctx: &mut FunctionContext<'_>, key_ty: &Ph emit_append_word_key_x86_64(ctx, "rax"); } PhpType::Str => { - ctx.emitter.instruction("mov rsi, rdx"); // move the string key length into the mixed helper high-word register - ctx.emitter.instruction("mov eax, 1"); // runtime tag 1 = string mixed key - abi::emit_call_label(ctx.emitter, "__rt_mixed_from_value"); + ctx.emitter.instruction("mov rax, rdi"); // pass the borrowed hash key pointer to the persistence helper + abi::emit_call_label(ctx.emitter, "__rt_str_persist"); + crate::codegen::emit_box_current_owned_value_as_mixed(ctx.emitter, &PhpType::Str); emit_append_word_key_x86_64(ctx, "rax"); } PhpType::Mixed => { @@ -576,9 +576,9 @@ fn emit_assoc_mixed_key_append_x86_64(ctx: &mut FunctionContext<'_>, key_ty: &Ph abi::emit_call_label(ctx.emitter, "__rt_mixed_from_value"); ctx.emitter.instruction(&format!("jmp {}", key_boxed)); // skip string-key boxing after producing an integer mixed key ctx.emitter.label(&key_string); - ctx.emitter.instruction("mov rsi, rdx"); // move the string key length into the mixed helper high-word register - ctx.emitter.instruction("mov eax, 1"); // runtime tag 1 = string mixed key - abi::emit_call_label(ctx.emitter, "__rt_mixed_from_value"); + ctx.emitter.instruction("mov rax, rdi"); // pass the borrowed hash key pointer to the persistence helper + abi::emit_call_label(ctx.emitter, "__rt_str_persist"); + crate::codegen::emit_box_current_owned_value_as_mixed(ctx.emitter, &PhpType::Str); ctx.emitter.label(&key_boxed); emit_append_word_key_x86_64(ctx, "rax"); } diff --git a/src/codegen/lower_inst/builtins/io/resource_handles.rs b/src/codegen/lower_inst/builtins/io/resource_handles.rs index 2334856d99..1e8cc6085d 100644 --- a/src/codegen/lower_inst/builtins/io/resource_handles.rs +++ b/src/codegen/lower_inst/builtins/io/resource_handles.rs @@ -288,9 +288,8 @@ pub(super) fn emit_stream_type_error_and_exit(ctx: &mut FunctionContext<'_>, lab ctx.emitter.instruction("mov eax, 1"); // select Linux x86_64 write syscall ctx.emitter.instruction("syscall"); // emit the stream TypeError diagnostic ctx.emitter.instruction("mov edi, 1"); // exit with status 1 after reporting the TypeError - ctx.emitter.instruction("mov eax, 60"); // select Linux x86_64 exit syscall + ctx.emitter.instruction("mov eax, 231"); // select Linux x86_64 exit_group syscall ctx.emitter.instruction("syscall"); // terminate the process after the fatal TypeError } } } - diff --git a/src/codegen/lower_inst/builtins/io/stream_file_ops.rs b/src/codegen/lower_inst/builtins/io/stream_file_ops.rs index 31d20bbc9d..aae9ffe783 100644 --- a/src/codegen/lower_inst/builtins/io/stream_file_ops.rs +++ b/src/codegen/lower_inst/builtins/io/stream_file_ops.rs @@ -140,7 +140,7 @@ pub(crate) fn lower_fread(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> store_if_result(ctx, inst) } -/// Lowers `fwrite(stream, data)` and returns the number of bytes written. +/// Lowers `fwrite(stream, data)` and boxes a byte count or PHP `false` on error. pub(crate) fn lower_fwrite(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { super::super::ensure_arg_count(inst, "fwrite", 2)?; let stream = expect_operand(inst, 0)?; @@ -161,6 +161,7 @@ pub(crate) fn lower_fwrite(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> abi::emit_call_label(ctx.emitter, "__rt_fwrite"); } } + box_negative_int_or_false_result(ctx, "fwrite"); store_if_result(ctx, inst) } @@ -337,4 +338,3 @@ pub(crate) fn lower_fpassthru(ctx: &mut FunctionContext<'_>, inst: &Instruction) emit_fpassthru_dispatch(ctx); store_if_result(ctx, inst) } - diff --git a/src/codegen/lower_inst/builtins/isset.rs b/src/codegen/lower_inst/builtins/isset.rs index 97e1c32410..122e61d007 100644 --- a/src/codegen/lower_inst/builtins/isset.rs +++ b/src/codegen/lower_inst/builtins/isset.rs @@ -276,10 +276,16 @@ fn emit_isset_hash_found_null_check_aarch64( missing: &str, ) -> Result<()> { if matches!(value_ty.codegen_repr(), PhpType::Mixed) { + let concrete_non_null = ctx.next_label("isset_hash_concrete_non_null"); + ctx.emitter.instruction("cmp x3, #8"); // detect an unboxed per-entry null tag before inspecting its payload + ctx.emitter.instruction(&format!("b.eq {}", missing)); // concrete null entries make isset return false + ctx.emitter.instruction("cmp x3, #7"); // tag 7 means value_lo points to an already-boxed Mixed cell + ctx.emitter.instruction(&format!("b.ne {}", concrete_non_null)); // every other concrete runtime tag is non-null ctx.emitter.instruction("mov x0, x1"); // pass the boxed Mixed hash value to the unbox helper abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); ctx.emitter.instruction("cmp x0, #8"); // runtime tag 8 means the found hash value is PHP null ctx.emitter.instruction(&format!("b.eq {}", missing)); // null hash values make isset return false + ctx.emitter.label(&concrete_non_null); return Ok(()); } ctx.emitter.instruction("cmp x3, #8"); // runtime tag 8 means the found hash value is PHP null @@ -294,10 +300,16 @@ fn emit_isset_hash_found_null_check_x86_64( missing: &str, ) -> Result<()> { if matches!(value_ty.codegen_repr(), PhpType::Mixed) { + let concrete_non_null = ctx.next_label("isset_hash_concrete_non_null"); + ctx.emitter.instruction("cmp rcx, 8"); // detect an unboxed per-entry null tag before inspecting its payload + ctx.emitter.instruction(&format!("je {}", missing)); // concrete null entries make isset return false + ctx.emitter.instruction("cmp rcx, 7"); // tag 7 means value_lo points to an already-boxed Mixed cell + ctx.emitter.instruction(&format!("jne {}", concrete_non_null)); // every other concrete runtime tag is non-null ctx.emitter.instruction("mov rax, rdi"); // pass the boxed Mixed hash value to the unbox helper abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); ctx.emitter.instruction("cmp rax, 8"); // runtime tag 8 means the found hash value is PHP null ctx.emitter.instruction(&format!("je {}", missing)); // null hash values make isset return false + ctx.emitter.label(&concrete_non_null); return Ok(()); } ctx.emitter.instruction("cmp rcx, 8"); // runtime tag 8 means the found hash value is PHP null @@ -306,6 +318,14 @@ fn emit_isset_hash_found_null_check_x86_64( } /// Emits AArch64 indexed-array `isset` offset bounds and null checks. +/// +/// The packed payload walk below is only valid on kind-2 (packed) storage, so it is +/// guarded by a runtime storage-kind dispatch: an `Array(_)`-typed local can still be +/// backed by *hash* storage, because a mixed-key write promotes the storage at runtime +/// while the checker only promotes the static type to `AssocArray` at a provably +/// string-keyed write. Without the guard, a promoted array bounds-checks the index +/// against the hash header's live-entry count and then reads the header's `head` field +/// as if it were a boxed element pointer. fn emit_isset_array_offset_missing_aarch64( ctx: &mut FunctionContext<'_>, array: ValueId, @@ -317,10 +337,21 @@ fn emit_isset_array_offset_missing_aarch64( let array_reg = abi::symbol_scratch_reg(ctx.emitter); let len_reg = abi::secondary_scratch_reg(ctx.emitter); let result_reg = abi::int_result_reg(ctx.emitter); + let promoted = ctx.next_label("isset_array_idx_promoted"); + ctx.load_value_to_reg(array, array_reg)?; + // -- guard the receiver before reading its storage-kind metadata -- + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + array_reg, + len_reg, + missing, + ); + ctx.emitter.instruction(&format!("ldr {}, [{}, #-8]", len_reg, array_reg)); // load the storage-kind metadata word from the array header + ctx.emitter.instruction(&format!("and {}, {}, #0xff", len_reg, len_reg)); // isolate the low byte holding the storage kind + ctx.emitter.instruction(&format!("cmp {}, #3", len_reg)); // kind 3 = storage was promoted to a hash at runtime + ctx.emitter.instruction(&format!("b.eq {}", promoted)); // a promoted array has no packed payload to index into ctx.load_value_to_reg(index, result_reg)?; ctx.load_value_to_reg(array, array_reg)?; - // -- guard the receiver: a missed outer read carries a null/sentinel container -- - crate::codegen::sentinels::emit_branch_if_null_container(ctx.emitter, array_reg, len_reg, missing); ctx.emitter.instruction(&format!("cmp {}, #0", result_reg)); // reject negative indexes as missing array elements ctx.emitter.instruction(&format!("b.lt {}", missing)); // missing indexes make isset return false abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); @@ -328,6 +359,9 @@ fn emit_isset_array_offset_missing_aarch64( ctx.emitter.instruction(&format!("b.ge {}", missing)); // out-of-bounds indexes make isset return false emit_isset_array_in_bounds_missing_aarch64(ctx, array_reg, result_reg, elem_ty)?; ctx.emitter.instruction(&format!("b {}", done)); // skip the out-of-bounds isset result after an in-bounds probe + ctx.emitter.label(&promoted); + emit_isset_promoted_hash_probe_aarch64(ctx, array, index, missing)?; + ctx.emitter.instruction(&format!("b {}", done)); // skip the missing result after a promoted-hash probe ctx.emitter.label(missing); abi::emit_load_int_immediate(ctx.emitter, result_reg, 1); ctx.emitter.label(done); @@ -335,6 +369,8 @@ fn emit_isset_array_offset_missing_aarch64( } /// Emits x86_64 indexed-array `isset` offset bounds and null checks. +/// +/// Storage-kind guarded exactly like the AArch64 emitter above. fn emit_isset_array_offset_missing_x86_64( ctx: &mut FunctionContext<'_>, array: ValueId, @@ -346,10 +382,21 @@ fn emit_isset_array_offset_missing_x86_64( let array_reg = abi::symbol_scratch_reg(ctx.emitter); let len_reg = abi::secondary_scratch_reg(ctx.emitter); let result_reg = abi::int_result_reg(ctx.emitter); + let promoted = ctx.next_label("isset_array_idx_promoted"); + ctx.load_value_to_reg(array, array_reg)?; + // -- guard the receiver before reading its storage-kind metadata -- + crate::codegen::sentinels::emit_branch_if_null_container( + ctx.emitter, + array_reg, + len_reg, + missing, + ); + ctx.emitter.instruction(&format!("mov {}, QWORD PTR [{} - 8]", len_reg, array_reg)); // load the storage-kind metadata word from the array header + ctx.emitter.instruction(&format!("and {}, 0xff", len_reg)); // isolate the low byte holding the storage kind + ctx.emitter.instruction(&format!("cmp {}, 3", len_reg)); // kind 3 = storage was promoted to a hash at runtime + ctx.emitter.instruction(&format!("je {}", promoted)); // a promoted array has no packed payload to index into ctx.load_value_to_reg(array, array_reg)?; ctx.load_value_to_reg(index, result_reg)?; - // -- guard the receiver: a missed outer read carries a null/sentinel container -- - crate::codegen::sentinels::emit_branch_if_null_container(ctx.emitter, array_reg, len_reg, missing); ctx.emitter.instruction(&format!("cmp {}, 0", result_reg)); // reject negative indexes as missing array elements ctx.emitter.instruction(&format!("jl {}", missing)); // missing indexes make isset return false abi::emit_load_from_address(ctx.emitter, len_reg, array_reg, 0); @@ -357,12 +404,74 @@ fn emit_isset_array_offset_missing_x86_64( ctx.emitter.instruction(&format!("jge {}", missing)); // out-of-bounds indexes make isset return false emit_isset_array_in_bounds_missing_x86_64(ctx, array_reg, result_reg, elem_ty)?; ctx.emitter.instruction(&format!("jmp {}", done)); // skip the out-of-bounds isset result after an in-bounds probe + ctx.emitter.label(&promoted); + emit_isset_promoted_hash_probe_x86_64(ctx, array, index, missing)?; + ctx.emitter.instruction(&format!("jmp {}", done)); // skip the missing result after a promoted-hash probe ctx.emitter.label(missing); abi::emit_load_int_immediate(ctx.emitter, result_reg, 1); ctx.emitter.label(done); Ok(()) } +/// Emits the AArch64 `isset` probe for an indexed array promoted to hash storage. +/// +/// Presence is decided from the *runtime* value tag `__rt_hash_get` reports rather than +/// from the array's static element type: a promoted array can hold an entry stored under +/// its own concrete tag as well as a boxed `Mixed` cell, and only tag 8 (null) — directly +/// or wrapped inside a boxed cell — makes `isset()` answer false. +fn emit_isset_promoted_hash_probe_aarch64( + ctx: &mut FunctionContext<'_>, + array: ValueId, + index: ValueId, + missing: &str, +) -> Result<()> { + let present = ctx.next_label("isset_array_idx_promoted_present"); + super::super::hashes::materialize_hash_key_aarch64(ctx, index)?; + ctx.load_value_to_reg(array, "x0")?; + abi::emit_call_label(ctx.emitter, "__rt_hash_get"); + ctx.emitter.instruction(&format!("cbz x0, {}", missing)); // the key is absent from the promoted hash storage + ctx.emitter.instruction("cmp x3, #8"); // runtime tag 8 means the stored value is PHP null + ctx.emitter.instruction(&format!("b.eq {}", missing)); // a null value is absent for isset (but present for array_key_exists) + ctx.emitter.instruction("cmp x3, #7"); // runtime tag 7 means the entry holds a boxed Mixed cell + ctx.emitter.instruction(&format!("b.ne {}", present)); // any other concrete tag is already a non-null value + ctx.emitter.instruction("mov x0, x1"); // pass the boxed Mixed cell to the unbox helper + abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); + ctx.emitter.instruction("cmp x0, #8"); // a boxed Mixed cell can still wrap PHP null + ctx.emitter.instruction(&format!("b.eq {}", missing)); // null-wrapping cells are absent for isset + ctx.emitter.label(&present); + ctx.emitter.instruction("mov x0, #0"); // a found non-null entry is present for isset + Ok(()) +} + +/// Emits the x86_64 `isset` probe for an indexed array promoted to hash storage. +/// +/// `__rt_hash_get`'s x86_64 ABI is *not* a mirror of its AArch64 one: it returns +/// `rax = found`, `rdi = value_lo`, `rsi = value_hi`, `rcx = value_tag`. +fn emit_isset_promoted_hash_probe_x86_64( + ctx: &mut FunctionContext<'_>, + array: ValueId, + index: ValueId, + missing: &str, +) -> Result<()> { + let present = ctx.next_label("isset_array_idx_promoted_present"); + super::super::hashes::materialize_hash_key_x86_64(ctx, index)?; + ctx.load_value_to_reg(array, "rdi")?; + abi::emit_call_label(ctx.emitter, "__rt_hash_get"); + ctx.emitter.instruction("test rax, rax"); // did the promoted hash storage hold the key? + ctx.emitter.instruction(&format!("jz {}", missing)); // the key is absent from the promoted hash storage + ctx.emitter.instruction("cmp rcx, 8"); // runtime tag 8 means the stored value is PHP null + ctx.emitter.instruction(&format!("je {}", missing)); // a null value is absent for isset (but present for array_key_exists) + ctx.emitter.instruction("cmp rcx, 7"); // runtime tag 7 means the entry holds a boxed Mixed cell + ctx.emitter.instruction(&format!("jne {}", present)); // any other concrete tag is already a non-null value + ctx.emitter.instruction("mov rax, rdi"); // pass the boxed Mixed cell to the unbox helper + abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); + ctx.emitter.instruction("cmp rax, 8"); // a boxed Mixed cell can still wrap PHP null + ctx.emitter.instruction(&format!("je {}", missing)); // null-wrapping cells are absent for isset + ctx.emitter.label(&present); + ctx.emitter.instruction("xor eax, eax"); // a found non-null entry is present for isset + Ok(()) +} + /// Emits AArch64 null handling for an in-bounds indexed-array `isset` probe. fn emit_isset_array_in_bounds_missing_aarch64( ctx: &mut FunctionContext<'_>, diff --git a/src/codegen/lower_inst/builtins/pointers.rs b/src/codegen/lower_inst/builtins/pointers.rs index 5449d57a10..29374550ff 100644 --- a/src/codegen/lower_inst/builtins/pointers.rs +++ b/src/codegen/lower_inst/builtins/pointers.rs @@ -100,6 +100,167 @@ pub(crate) fn lower_ptr_offset(ctx: &mut FunctionContext<'_>, inst: &Instruction store_if_result(ctx, inst) } +/// Lowers `__elephc_callable_ptr($cb)` — the runtime value of a closure or +/// first-class callable already IS the raw pointer to its 64-byte descriptor, so +/// this is a bare identity load into the pointer result register. +/// +/// Dynamic PHP callable forms are normalized into the same descriptor ABI: strings +/// select a static function/method descriptor, arrays select a static or receiver- +/// bound method descriptor, invokable objects bind `__invoke`, and boxed callable +/// descriptors preserve their existing payload. +pub(crate) fn lower_elephc_callable_ptr( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + ensure_arg_count(inst, "__elephc_callable_ptr", 1)?; + let value = expect_operand(inst, 0)?; + match ctx.value_php_type(value)? { + PhpType::Callable => { + ctx.load_value_to_result(value)?; + } + PhpType::Mixed | PhpType::Union(_) => { + super::super::callables::emit_runtime_mixed_callable_descriptor_value( + ctx, + value, + "__elephc_callable_ptr", + false, + )?; + } + PhpType::Str => { + let result_reg = abi::int_result_reg(ctx.emitter).to_string(); + super::super::callables::emit_runtime_string_descriptor_value( + ctx, + value, + &result_reg, + "__elephc_callable_ptr", + crate::strict_php::is_enabled(), + )?; + } + PhpType::Array(element) + if matches!(element.codegen_repr(), PhpType::Mixed | PhpType::Str) => + { + super::super::callables::emit_runtime_callable_array_descriptor_value( + ctx, + value, + "__elephc_callable_ptr", + )?; + } + PhpType::Object(class_name) => { + super::super::callables::emit_invokable_object_descriptor_value( + ctx, + value, + &class_name, + "__elephc_callable_ptr", + )?; + } + other => { + return Err(CodegenIrError::unsupported(format!( + "__elephc_callable_ptr requires a PHP callable value, got {:?}", + other + ))); + } + } + store_if_result(ctx, inst) +} + +/// Lowers `__elephc_normalize_callable($cb)` into an owned callable descriptor. +/// +/// Static descriptors tolerate retains as persistent values, while runtime descriptors +/// selected from an existing `Callable` or boxed callable need one additional owner for +/// the returned value. Fresh receiver-bound descriptors already start with one owner. +pub(crate) fn lower_elephc_normalize_callable( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + ensure_arg_count(inst, "__elephc_normalize_callable", 1)?; + let value = expect_operand(inst, 0)?; + match ctx.value_php_type(value)? { + PhpType::Callable => { + ctx.load_value_to_result(value)?; + abi::emit_incref_if_refcounted(ctx.emitter, &PhpType::Callable); + } + PhpType::Mixed | PhpType::Union(_) => { + super::super::callables::emit_runtime_mixed_callable_descriptor_value( + ctx, + value, + "__elephc_normalize_callable", + true, + )?; + } + PhpType::Str => { + let result_reg = abi::int_result_reg(ctx.emitter).to_string(); + super::super::callables::emit_runtime_string_descriptor_value( + ctx, + value, + &result_reg, + "__elephc_normalize_callable", + crate::strict_php::is_enabled(), + )?; + } + PhpType::Array(element) + if matches!(element.codegen_repr(), PhpType::Mixed | PhpType::Str) => + { + super::super::callables::emit_runtime_callable_array_descriptor_value( + ctx, + value, + "__elephc_normalize_callable", + )?; + } + PhpType::Object(class_name) => { + super::super::callables::emit_invokable_object_descriptor_value( + ctx, + value, + &class_name, + "__elephc_normalize_callable", + )?; + } + other => { + return Err(CodegenIrError::unsupported(format!( + "__elephc_normalize_callable requires a PHP callable value, got {:?}", + other + ))); + } + } + store_if_result(ctx, inst) +} + +/// Lowers `__elephc_pdo_adapter_addr($kind)` — materializes the GOT address of the +/// shared codegen PDO callback adapter selected by the constant `$kind` +/// (0 = collation, 1 = scalar user function, 2 = aggregate step, 3 = aggregate +/// finalize). The bridge stores this address per registration and calls it back with +/// the database-provided arguments, so no bridge extern references a `__rt_*` symbol +/// directly. +/// +/// The adapter is an external runtime symbol (emitted in the runtime `.text` +/// section, gated by `RuntimeFeatures::pdo_udf`), so its address is taken through +/// the GOT rather than a same-section page relocation. +pub(crate) fn lower_elephc_pdo_adapter_addr( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + ensure_arg_count(inst, "__elephc_pdo_adapter_addr", 1)?; + let kind = const_i64_operand(ctx, expect_operand(inst, 0)?)?; + let symbol = match kind { + 0 => "__rt_pdo_call_collation", + 1 => "__rt_pdo_call_scalar", + 2 => "__rt_pdo_call_agg_step", + 3 => "__rt_pdo_call_agg_final", + _ => { + return Err(CodegenIrError::unsupported(format!( + "__elephc_pdo_adapter_addr has no adapter for kind {}", + kind + ))); + } + }; + // `__rt_pdo_call_collation` is a raw runtime assembly label emitted verbatim by + // `label_global` (like every other `__rt_*` helper), NOT a C symbol — so it must + // be referenced without the platform's leading-underscore mangling that + // `Target::extern_symbol` would add on Mach-O. Take its address through the GOT + // (the helper lives in the separately-assembled runtime object). + abi::emit_extern_symbol_address(ctx.emitter, abi::int_result_reg(ctx.emitter), symbol); + store_if_result(ctx, inst) +} + /// Lowers `ptr_get(pointer)` by reading one machine word through a checked pointer. pub(crate) fn lower_ptr_get(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { lower_pointer_read(ctx, inst, "ptr_get", PointerWidth::Word64) @@ -216,6 +377,38 @@ fn const_string_operand(ctx: &FunctionContext<'_>, value: ValueId) -> Result, value: ValueId) -> Result { + let value_ref = ctx + .function + .value(value) + .ok_or_else(|| CodegenIrError::missing_entry("value", value.as_raw()))?; + let ValueDef::Instruction { inst, .. } = value_ref.def else { + return Err(CodegenIrError::invalid_module( + "__elephc_pdo_adapter_addr kind must be a constant integer literal", + )); + }; + let inst_ref = ctx + .function + .instruction(inst) + .ok_or_else(|| CodegenIrError::missing_entry("instruction", inst.as_raw()))?; + if inst_ref.op != Op::ConstI64 { + return Err(CodegenIrError::invalid_module( + "__elephc_pdo_adapter_addr kind must be a constant integer literal", + )); + } + match inst_ref.immediate { + Some(Immediate::I64(literal)) => Ok(literal), + _ => Err(CodegenIrError::invalid_module( + "__elephc_pdo_adapter_addr kind literal has no i64 immediate", + )), + } +} + /// Addressable storage source accepted by the `ptr()` builtin. enum PointerSource { Local { slot: LocalSlotId }, diff --git a/src/codegen/lower_inst/builtins/system.rs b/src/codegen/lower_inst/builtins/system.rs index dac4073b7d..b620c0edbb 100644 --- a/src/codegen/lower_inst/builtins/system.rs +++ b/src/codegen/lower_inst/builtins/system.rs @@ -549,6 +549,51 @@ pub(crate) fn lower_elephc_strtotime_raw( store_if_result(ctx, inst) } +/// Tests whether a dynamically named AOT class exposes an inherited or declared constructor. +pub(crate) fn lower_elephc_class_has_constructor( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + super::ensure_arg_count(inst, "__elephc_class_has_constructor", 1)?; + super::super::objects::lower_dynamic_class_has_constructor(ctx, inst) +} + +/// Classifies a dynamically named class for PDO's custom statement construction rules. +pub(crate) fn lower_elephc_pdo_statement_class_status( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + super::ensure_arg_count(inst, "__elephc_pdo_statement_class_status", 1)?; + super::super::objects::lower_dynamic_pdo_statement_class_status(ctx, inst) +} + +/// Classifies the late-static called class for `PDO::connect()` driver validation. +pub(crate) fn lower_elephc_pdo_called_class_status( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + super::ensure_arg_count(inst, "__elephc_pdo_called_class_status", 1)?; + super::super::objects::lower_dynamic_pdo_called_class_status(ctx, inst) +} + +/// Invokes a selected PDOStatement subclass constructor after its native state is initialized. +pub(crate) fn lower_elephc_invoke_pdo_statement_constructor( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + super::ensure_arg_count(inst, "__elephc_invoke_pdo_statement_constructor", 3)?; + super::super::objects::lower_dynamic_pdo_statement_constructor_call(ctx, inst) +} + +/// Initializes the private PDOStatement base fields on a dynamically allocated subclass. +pub(crate) fn lower_elephc_initialize_pdo_statement( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + super::ensure_arg_count(inst, "__elephc_initialize_pdo_statement", 5)?; + super::super::objects::lower_dynamic_pdo_statement_initialize(ctx, inst) +} + /// Marshals the shared `__rt_strtotime` ABI for `strtotime` / `__elephc_strtotime_raw`. /// /// Loads the datetime string (`x1`/`x2` on ARM64, `rdi`/`rsi` on x86_64), the optional base diff --git a/src/codegen/lower_inst/callables.rs b/src/codegen/lower_inst/callables.rs index 0446df24df..245f00f701 100644 --- a/src/codegen/lower_inst/callables.rs +++ b/src/codegen/lower_inst/callables.rs @@ -333,6 +333,153 @@ fn lower_mixed_callable_descriptor_invoke( Ok(()) } +/// Materializes a descriptor pointer for any callable shape boxed in `Mixed`. +/// +/// PDO's SQLite callbacks keep this descriptor for later native invocation, so this normalizes +/// strings, callable arrays, invokable objects, and existing callable descriptors without calling +/// the PHP target at registration time. +pub(super) fn emit_runtime_mixed_callable_descriptor_value( + ctx: &mut FunctionContext<'_>, + callable: ValueId, + op_name: &str, + retain_existing_descriptor: bool, +) -> Result<()> { + let instance_targets = runtime_array_instance_method_targets_for_descriptor(ctx); + let invokable_targets = instance_targets + .iter() + .filter(|target| target.method_key == "__invoke") + .cloned() + .collect::>(); + let static_cases = runtime_static_method_descriptor_cases(ctx, None); + let array_label = (!instance_targets.is_empty() || !static_cases.is_empty()) + .then(|| ctx.next_label("mixed_callable_value_array")); + let object_label = (!invokable_targets.is_empty()) + .then(|| ctx.next_label("mixed_callable_value_object")); + let descriptor_label = ctx.next_label("mixed_callable_value_descriptor"); + let string_label = ctx.next_label("mixed_callable_value_string"); + let fatal_label = ctx.next_label("mixed_callable_value_not_callable"); + let done_label = ctx.next_label("mixed_callable_value_done"); + + match ctx.emitter.target.arch { + Arch::AArch64 => { + ctx.load_value_to_reg(callable, "x0")?; + abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); + ctx.emitter.instruction(&format!("cmp x0, #{}", MIXED_TAG_CALLABLE)); // classify an existing callable descriptor + ctx.emitter.instruction(&format!("b.eq {}", descriptor_label)); // return the existing descriptor payload + ctx.emitter.instruction(&format!("cmp x0, #{}", MIXED_TAG_STRING)); // classify a runtime callable name + ctx.emitter.instruction(&format!("b.eq {}", string_label)); // resolve the callable name through the descriptor table + if let Some(array_label) = &array_label { + ctx.emitter.instruction(&format!("cmp x0, #{}", MIXED_TAG_INDEXED_ARRAY)); // classify a two-element callable array + ctx.emitter.instruction(&format!("b.eq {}", array_label)); // resolve an instance/static method descriptor + } + if let Some(object_label) = &object_label { + ctx.emitter.instruction(&format!("cmp x0, #{}", MIXED_TAG_OBJECT)); // classify an invokable object + ctx.emitter.instruction(&format!("b.eq {}", object_label)); // bind the public __invoke descriptor + } + } + Arch::X86_64 => { + ctx.load_value_to_reg(callable, "rax")?; + abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); + ctx.emitter.instruction(&format!("cmp rax, {}", MIXED_TAG_CALLABLE)); // classify an existing callable descriptor + ctx.emitter.instruction(&format!("je {}", descriptor_label)); // return the existing descriptor payload + ctx.emitter.instruction(&format!("cmp rax, {}", MIXED_TAG_STRING)); // classify a runtime callable name + ctx.emitter.instruction(&format!("je {}", string_label)); // resolve the callable name through the descriptor table + if let Some(array_label) = &array_label { + ctx.emitter.instruction(&format!("cmp rax, {}", MIXED_TAG_INDEXED_ARRAY)); // classify a two-element callable array + ctx.emitter.instruction(&format!("je {}", array_label)); // resolve an instance/static method descriptor + } + if let Some(object_label) = &object_label { + ctx.emitter.instruction(&format!("cmp rax, {}", MIXED_TAG_OBJECT)); // classify an invokable object + ctx.emitter.instruction(&format!("je {}", object_label)); // bind the public __invoke descriptor + } + } + } + abi::emit_jump(ctx.emitter, &fatal_label); + + ctx.emitter.label(&descriptor_label); + match ctx.emitter.target.arch { + Arch::AArch64 => ctx.emitter.instruction("mov x0, x1"), // return the unboxed descriptor payload + Arch::X86_64 => ctx.emitter.instruction("mov rax, rdi"), // return the unboxed descriptor payload + } + if retain_existing_descriptor { + callable_descriptor::emit_retain_current_descriptor(ctx.emitter); + } + abi::emit_jump(ctx.emitter, &done_label); + + ctx.emitter.label(&string_label); + emit_runtime_string_descriptor_value_from_unboxed(ctx, op_name)?; + abi::emit_jump(ctx.emitter, &done_label); + + if let Some(array_label) = &array_label { + ctx.emitter.label(array_label); + emit_mixed_callable_array_selector_slots(ctx, &CallableArraySource::BoxedArray(callable))?; + let selected_label = ctx.next_label("mixed_callable_value_array_done"); + for target in &instance_targets { + let next_label = ctx.next_label("mixed_callable_value_array_instance_next"); + emit_branch_if_runtime_array_instance_mismatch(ctx, target, &next_label); + emit_runtime_array_instance_descriptor_value(ctx, target)?; + abi::emit_jump(ctx.emitter, &selected_label); + ctx.emitter.label(&next_label); + } + for case in &static_cases { + let next_label = ctx.next_label("mixed_callable_value_array_static_next"); + emit_branch_if_mixed_static_case_mismatch(ctx, case, &next_label); + abi::emit_symbol_address( + ctx.emitter, + abi::int_result_reg(ctx.emitter), + &case.case.descriptor_label, + ); + abi::emit_jump(ctx.emitter, &selected_label); + ctx.emitter.label(&next_label); + } + emit_runtime_callable_array_no_match_abort(ctx); + ctx.emitter.label(&selected_label); + abi::emit_release_temporary_stack(ctx.emitter, MIXED_SELECTOR_BYTES); + abi::emit_jump(ctx.emitter, &done_label); + } + + if let Some(object_label) = &object_label { + ctx.emitter.label(object_label); + emit_push_mixed_unbox_payload(ctx); + let selected_label = ctx.next_label("mixed_callable_value_object_done"); + for target in &invokable_targets { + let next_label = ctx.next_label("mixed_callable_value_object_next"); + emit_branch_if_saved_receiver_class_id_mismatch( + ctx, + target.class_id, + MIXED_VALUE_PAYLOAD_OFFSET, + &next_label, + ); + let receiver_ty = PhpType::Object(target.class_name.clone()); + let template = runtime_instance_method_descriptor_template( + ctx, + &target.class_name, + &target.method_name, + &target.method_key, + &target.impl_class, + &target.sig, + )?; + emit_runtime_descriptor_with_saved_receiver_capture( + ctx, + &template.descriptor_label, + &receiver_ty, + MIXED_VALUE_PAYLOAD_OFFSET, + ); + abi::emit_jump(ctx.emitter, &selected_label); + ctx.emitter.label(&next_label); + } + emit_mixed_callable_not_callable_fatal(ctx, op_name); + ctx.emitter.label(&selected_label); + abi::emit_release_temporary_stack(ctx.emitter, MIXED_VALUE_BYTES); + abi::emit_jump(ctx.emitter, &done_label); + } + + ctx.emitter.label(&fatal_label); + emit_mixed_callable_not_callable_fatal(ctx, op_name); + ctx.emitter.label(&done_label); + Ok(()) +} + /// Emits a fatal diagnostic for a boxed Mixed value that is called but is not callable. fn emit_mixed_callable_not_callable_fatal(ctx: &mut FunctionContext<'_>, op_name: &str) { let message = format!( @@ -382,7 +529,6 @@ fn lower_runtime_string_descriptor_invoke( "callable_descriptor_invoke for runtime string with no descriptor targets", )); } - let (ptr_reg, len_reg) = abi::string_result_regs(ctx.emitter); ctx.load_string_value_to_regs(callable, ptr_reg, len_reg)?; abi::emit_push_reg_pair(ctx.emitter, ptr_reg, len_reg); @@ -726,6 +872,60 @@ pub(super) fn emit_runtime_string_descriptor_value( Ok(()) } +/// Selects a callable descriptor from a string payload just returned by `__rt_mixed_unbox`. +fn emit_runtime_string_descriptor_value_from_unboxed( + ctx: &mut FunctionContext<'_>, + op_name: &str, +) -> Result<()> { + let cases = runtime_string_descriptor_cases( + ctx, + None, + None, + crate::strict_php::is_enabled(), + )?; + if cases.is_empty() { + return Err(CodegenIrError::unsupported(format!( + "{} for runtime string with no descriptor targets", + op_name + ))); + } + + let (ptr_reg, len_reg) = abi::string_result_regs(ctx.emitter); + if ctx.emitter.target.arch == Arch::X86_64 { + ctx.emitter.instruction(&format!("mov {}, rdi", ptr_reg)); // move the unboxed string pointer into the canonical string result register + } + abi::emit_push_reg_pair(ctx.emitter, ptr_reg, len_reg); + + let done_label = ctx.next_label(&format!("{}_mixed_string_descriptor_done", op_name)); + let miss_label = ctx.next_label(&format!("{}_mixed_string_descriptor_missing", op_name)); + let selector = callable_dispatch::RuntimeCallableSelector::StringNameStack { + ptr_offset: 0, + len_offset: 8, + call_reg: abi::int_result_reg(ctx.emitter), + }; + for case in &cases { + let next_case = ctx.next_label("mixed_string_descriptor_next"); + let matched_label = ctx.next_label("mixed_string_descriptor_match"); + callable_dispatch::emit_branch_if_callable_case_mismatch( + &selector, + case, + &next_case, + ctx.emitter, + &matched_label, + ctx.data, + ); + abi::emit_jump(ctx.emitter, &done_label); + ctx.emitter.label(&next_case); + } + abi::emit_jump(ctx.emitter, &miss_label); + + ctx.emitter.label(&miss_label); + emit_undefined_runtime_string_call_fatal(ctx); + ctx.emitter.label(&done_label); + abi::emit_release_temporary_stack(ctx.emitter, 16); + Ok(()) +} + /// Lowers `call_user_func_array($object, $args)` through an `__invoke` descriptor. fn lower_invokable_object_descriptor_invoke( ctx: &mut FunctionContext<'_>, diff --git a/src/codegen/lower_inst/comparisons.rs b/src/codegen/lower_inst/comparisons.rs index c99b3077ba..6eb0c969d1 100644 --- a/src/codegen/lower_inst/comparisons.rs +++ b/src/codegen/lower_inst/comparisons.rs @@ -75,6 +75,13 @@ pub(super) fn lower_strict_eq( emit_mixed_strict_compare(ctx, lhs, &lhs_ty, rhs, &rhs_ty, is_equal)?; return store_if_result(ctx, inst); } + if is_array_like(&lhs_ty) && is_array_like(&rhs_ty) { + // Two concrete array/hash operands compare by deep PHP structure, not pointer identity or + // static element type; the static element types may even differ (e.g. `Array(Int)` vs the + // empty `Array(Never)`) while the runtime values are structurally equal. + emit_array_deep_eq_call(ctx, lhs, rhs, is_equal)?; + return store_if_result(ctx, inst); + } if matches!( (&lhs_ty, &rhs_ty), (PhpType::Object(_), PhpType::Object(_)) @@ -135,6 +142,48 @@ fn emit_pointer_compare( Ok(()) } +/// Returns true when a codegen type is a concrete array or hash payload (an indexed `Array` or an +/// associative `AssocArray`), whose strict comparison must recurse into the deep-equality helper. +fn is_array_like(ty: &PhpType) -> bool { + matches!(ty, PhpType::Array(_) | PhpType::AssocArray { .. }) +} + +/// Emits a deep structural strict-equality comparison for two concrete array/hash operands by +/// calling `__rt_array_strict_eq` on the raw array pointers. The operands are staged through the +/// temporary stack so the argument registers cannot clobber each other, and the boolean result is +/// inverted for the `!==` form. No boxing or refcount mutation occurs: the helper is read-only. +fn emit_array_deep_eq_call( + ctx: &mut FunctionContext<'_>, + lhs: ValueId, + rhs: ValueId, + is_equal: bool, +) -> Result<()> { + ctx.load_value_to_result(lhs)?; + abi::emit_push_reg(ctx.emitter, abi::int_result_reg(ctx.emitter)); + ctx.load_value_to_result(rhs)?; + abi::emit_push_reg(ctx.emitter, abi::int_result_reg(ctx.emitter)); + match ctx.emitter.target.arch { + Arch::AArch64 => { + abi::emit_load_temporary_stack_slot(ctx.emitter, "x0", 16); + abi::emit_load_temporary_stack_slot(ctx.emitter, "x1", 0); + abi::emit_call_label(ctx.emitter, "__rt_array_strict_eq"); + if !is_equal { + ctx.emitter.instruction("eor x0, x0, #1"); // invert deep array equality for inequality + } + } + Arch::X86_64 => { + abi::emit_load_temporary_stack_slot(ctx.emitter, "rdi", 16); + abi::emit_load_temporary_stack_slot(ctx.emitter, "rsi", 0); + abi::emit_call_label(ctx.emitter, "__rt_array_strict_eq"); + if !is_equal { + ctx.emitter.instruction("xor rax, 1"); // invert deep array equality for inequality + } + } + } + abi::emit_release_temporary_stack(ctx.emitter, 32); + Ok(()) +} + /// Returns true for boxed runtime payloads that need mixed-aware comparison. fn is_mixed_like(ty: &PhpType) -> bool { matches!(ty.codegen_repr(), PhpType::Mixed) diff --git a/src/codegen/lower_inst/externs.rs b/src/codegen/lower_inst/externs.rs index 83de276a50..be677f1112 100644 --- a/src/codegen/lower_inst/externs.rs +++ b/src/codegen/lower_inst/externs.rs @@ -75,7 +75,7 @@ pub(super) fn lower_extern_call(ctx: &mut FunctionContext<'_>, inst: &Instructio let assignments = abi::build_outgoing_arg_assignments_for_target(ctx.emitter.target, &c_param_types, 0); - let overflow_bytes = abi::materialize_outgoing_args(ctx.emitter, &assignments); + let overflow_bytes = abi::materialize_outgoing_c_abi_args(ctx.emitter, &assignments); let symbol = ctx.emitter.target.extern_symbol(&decl.name); abi::emit_call_label(ctx.emitter, &symbol); abi::emit_release_temporary_stack(ctx.emitter, overflow_bytes); diff --git a/src/codegen/lower_inst/globals_constants.rs b/src/codegen/lower_inst/globals_constants.rs index 1ad66f3e0e..7ad8531c46 100644 --- a/src/codegen/lower_inst/globals_constants.rs +++ b/src/codegen/lower_inst/globals_constants.rs @@ -193,6 +193,14 @@ pub(super) fn lower_mixed_box(ctx: &mut FunctionContext<'_>, inst: &Instruction) store_if_result(ctx, inst) } +/// Clones a boxed Mixed zval cell so later mutation cannot rewrite an aliased source cell. +pub(super) fn lower_mixed_clone(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { + let value = expect_operand(inst, 0)?; + load_value_to_first_int_arg(ctx, value)?; + abi::emit_call_label(ctx.emitter, "__rt_mixed_clone"); + store_if_result(ctx, inst) +} + /// Lowers an invoker-only by-reference argument marker for descriptor calls. pub(super) fn lower_invoker_ref_arg(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { let slot = expect_local_slot(inst)?; @@ -215,4 +223,3 @@ pub(super) fn lower_invoker_ref_arg(ctx: &mut FunctionContext<'_>, inst: &Instru emit_box_runtime_payload_as_mixed(ctx.emitter, marker_tag_reg, ref_cell_reg, source_tag_reg); store_if_result(ctx, inst) } - diff --git a/src/codegen/lower_inst/hashes.rs b/src/codegen/lower_inst/hashes.rs index 28a73e13dd..7398441dda 100644 --- a/src/codegen/lower_inst/hashes.rs +++ b/src/codegen/lower_inst/hashes.rs @@ -123,10 +123,28 @@ pub(super) fn lower_hash_get( let result_ty = inst.result_php_type.codegen_repr(); match ctx.emitter.target.arch { Arch::AArch64 => { - lower_hash_get_aarch64(ctx, inst, hash, key, &value_ty, &result_ty, warn_on_missing) + lower_hash_get_aarch64( + ctx, + inst, + hash, + key, + &value_ty, + &result_ty, + warn_on_missing, + false, + ) } Arch::X86_64 => { - lower_hash_get_x86_64(ctx, inst, hash, key, &value_ty, &result_ty, warn_on_missing) + lower_hash_get_x86_64( + ctx, + inst, + hash, + key, + &value_ty, + &result_ty, + warn_on_missing, + false, + ) } } } @@ -146,6 +164,8 @@ pub(super) fn lower_hash_get( /// from `__rt_hash_get`'s entry-address output (`x4` / `r8`), which the probe already builds. /// Everything downstream is identical: split the container the slot holds, store the unique /// pointer straight back into that slot, hand the result out BORROWED — the entry owns it. +/// Boxed `Mixed` entries instead retain their owning cell for the nested-write path, which later +/// republishes replacements into the matching entry. pub(super) fn lower_hash_get_for_write( ctx: &mut FunctionContext<'_>, inst: &Instruction, @@ -153,11 +173,36 @@ pub(super) fn lower_hash_get_for_write( let hash = expect_operand(inst, 0)?; let key = expect_operand(inst, 1)?; let value_ty = assoc_value_type(&ctx.value_php_type(hash)?, inst)?; + require_hash_get_result(&value_ty, inst)?; + let result_ty = inst.result_php_type.codegen_repr(); + if matches!(result_ty, PhpType::Mixed) { + return match ctx.emitter.target.arch { + Arch::AArch64 => lower_hash_get_aarch64( + ctx, + inst, + hash, + key, + &value_ty, + &result_ty, + true, + true, + ), + Arch::X86_64 => lower_hash_get_x86_64( + ctx, + inst, + hash, + key, + &value_ty, + &result_ty, + true, + true, + ), + }; + } let helper = super::arrays::array_get_for_write_cow_helper(&value_ty).ok_or_else(|| { CodegenIrError::unsupported(format!("hash_get_for_write value PHP type {:?}", value_ty)) })?; super::arrays::separate_get_for_write_receiver(ctx, hash, "__rt_hash_ensure_unique")?; - let result_ty = inst.result_php_type.codegen_repr(); match ctx.emitter.target.arch { Arch::AArch64 => lower_hash_get_for_write_aarch64(ctx, inst, hash, key, &result_ty, helper), Arch::X86_64 => lower_hash_get_for_write_x86_64(ctx, inst, hash, key, &result_ty, helper), @@ -428,6 +473,7 @@ fn lower_hash_get_aarch64( value_ty: &PhpType, result_ty: &PhpType, warn_on_missing: bool, + for_write: bool, ) -> Result<()> { materialize_hash_key_aarch64(ctx, key)?; ctx.load_value_to_reg(hash, "x0")?; @@ -443,7 +489,11 @@ fn lower_hash_get_aarch64( ); abi::emit_call_label(ctx.emitter, "__rt_hash_get"); ctx.emitter.instruction(&format!("cbz x0, {}", miss)); // branch to the null fallback when the associative lookup misses - emit_hash_get_success_aarch64(ctx, value_ty, result_ty)?; + if for_write && matches!(value_ty, PhpType::Mixed) { + emit_hash_get_mixed_for_write_aarch64(ctx, hash, key)?; + } else { + emit_hash_get_success_aarch64(ctx, value_ty, result_ty, false)?; + } ctx.emitter.instruction(&format!("b {}", done)); // skip the miss fallback after materializing the hash value ctx.emitter.label(&miss); if warn_on_missing { @@ -469,6 +519,7 @@ fn lower_hash_get_x86_64( value_ty: &PhpType, result_ty: &PhpType, warn_on_missing: bool, + for_write: bool, ) -> Result<()> { materialize_hash_key_x86_64(ctx, key)?; ctx.load_value_to_reg(hash, "rdi")?; @@ -485,7 +536,11 @@ fn lower_hash_get_x86_64( abi::emit_call_label(ctx.emitter, "__rt_hash_get"); ctx.emitter.instruction("test rax, rax"); // check whether the associative lookup found a matching key ctx.emitter.instruction(&format!("jz {}", miss)); // branch to the null fallback when the associative lookup misses - emit_hash_get_success_x86_64(ctx, value_ty, result_ty)?; + if for_write && matches!(value_ty, PhpType::Mixed) { + emit_hash_get_mixed_for_write_x86_64(ctx, hash, key)?; + } else { + emit_hash_get_success_x86_64(ctx, value_ty, result_ty, false)?; + } ctx.emitter.instruction(&format!("jmp {}", done)); // skip the miss fallback after materializing the hash value ctx.emitter.label(&miss); if warn_on_missing { @@ -779,6 +834,7 @@ fn materialize_mixed_hash_key_aarch64( ) -> Result<()> { let string_key = ctx.next_label("mixed_hash_key_string"); let null_key = ctx.next_label("mixed_hash_key_null"); + let float_key = ctx.next_label("mixed_hash_key_float"); let scalar_key = ctx.next_label("mixed_hash_key_scalar"); let done = ctx.next_label("mixed_hash_key_done"); ctx.load_value_to_reg(key, "x0")?; @@ -791,7 +847,13 @@ fn materialize_mixed_hash_key_aarch64( ctx.emitter.instruction(&format!("b.eq {}", scalar_key)); // keep integer keys as integer hash keys ctx.emitter.instruction("cmp x0, #3"); // boolean mixed keys normalize like integer keys ctx.emitter.instruction(&format!("b.eq {}", scalar_key)); // keep boolean keys as integer hash keys + ctx.emitter.instruction("cmp x0, #2"); // float mixed keys truncate toward zero, exactly like PHP + ctx.emitter.instruction(&format!("b.eq {}", float_key)); // route float keys through the truncating conversion ctx.emitter.instruction("mov x1, #0"); // unsupported mixed key tags fall back to integer key zero + ctx.emitter.instruction(&format!("b {}", scalar_key)); // the float arm sits between here and the scalar path + ctx.emitter.label(&float_key); + ctx.emitter.instruction("fmov d0, x1"); // move the raw IEEE-754 payload bits into an FP register + ctx.emitter.instruction("fcvtzs x1, d0"); // truncate toward zero: PHP casts a float array key to int ctx.emitter.label(&scalar_key); ctx.emitter.instruction("mov x2, #-1"); // key_hi sentinel marks scalar mixed keys as integers ctx.emitter.instruction(&format!("b {}", done)); // skip string-key normalization after scalar selection @@ -811,6 +873,7 @@ fn materialize_mixed_hash_key_x86_64( ) -> Result<()> { let string_key = ctx.next_label("mixed_hash_key_string"); let null_key = ctx.next_label("mixed_hash_key_null"); + let float_key = ctx.next_label("mixed_hash_key_float"); let scalar_key = ctx.next_label("mixed_hash_key_scalar"); let done = ctx.next_label("mixed_hash_key_done"); ctx.load_value_to_reg(key, "rax")?; @@ -823,9 +886,16 @@ fn materialize_mixed_hash_key_x86_64( ctx.emitter.instruction(&format!("je {}", scalar_key)); // keep integer keys as integer hash keys ctx.emitter.instruction("cmp rax, 3"); // boolean mixed keys normalize like integer keys ctx.emitter.instruction(&format!("je {}", scalar_key)); // keep boolean keys as integer hash keys + ctx.emitter.instruction("cmp rax, 2"); // float mixed keys truncate toward zero, exactly like PHP + ctx.emitter.instruction(&format!("je {}", float_key)); // route float keys through the truncating conversion ctx.emitter.instruction("xor esi, esi"); // unsupported mixed key tags fall back to integer key zero ctx.emitter.instruction("mov rdx, -1"); // key_hi sentinel marks fallback mixed keys as integers ctx.emitter.instruction(&format!("jmp {}", done)); // skip string-key normalization after fallback selection + ctx.emitter.label(&float_key); + ctx.emitter.instruction("movq xmm0, rdi"); // move the raw IEEE-754 payload bits into an FP register + ctx.emitter.instruction("cvttsd2si rsi, xmm0"); // truncate toward zero: PHP casts a float array key to int + ctx.emitter.instruction("mov rdx, -1"); // key_hi sentinel marks the truncated float as an integer key + ctx.emitter.instruction(&format!("jmp {}", done)); // skip string-key normalization after the float conversion ctx.emitter.label(&null_key); emit_empty_string_hash_key_x86_64(ctx); // null normalizes to the empty string "" hash key ctx.emitter.instruction(&format!("jmp {}", done)); // skip the string-key normalization path @@ -876,10 +946,16 @@ fn materialize_hash_value_aarch64( return materialize_hash_mixed_value_for_concrete_storage_aarch64(ctx, value, storage_value_ty); } match value_ty { - PhpType::Int | PhpType::Bool | PhpType::Callable | PhpType::Float => { + PhpType::Int | PhpType::Bool | PhpType::Float => { ctx.load_value_to_reg(value, "x3")?; ctx.emitter.instruction("mov x4, xzr"); // scalar associative-array payloads leave the high value word empty } + PhpType::Callable => { + ctx.load_value_to_result(value)?; + retain_hash_refcounted_value_if_borrowed(ctx, value, value_ty)?; + ctx.emitter.instruction("mov x3, x0"); // pass the owned callable descriptor as the hash value low word + ctx.emitter.instruction("mov x4, xzr"); // callable descriptors leave the high value word empty + } PhpType::Str => { ctx.load_string_value_to_regs(value, "x1", "x2")?; abi::emit_call_label(ctx.emitter, "__rt_str_persist"); @@ -916,10 +992,16 @@ fn materialize_hash_value_x86_64( return materialize_hash_mixed_value_for_concrete_storage_x86_64(ctx, value, storage_value_ty); } match value_ty { - PhpType::Int | PhpType::Bool | PhpType::Callable | PhpType::Float => { + PhpType::Int | PhpType::Bool | PhpType::Float => { ctx.load_value_to_reg(value, "rcx")?; ctx.emitter.instruction("xor r8, r8"); // scalar associative-array payloads leave the high value word empty } + PhpType::Callable => { + ctx.load_value_to_result(value)?; + retain_hash_refcounted_value_if_borrowed(ctx, value, value_ty)?; + ctx.emitter.instruction("mov rcx, rax"); // pass the owned callable descriptor as the hash value low word + ctx.emitter.instruction("xor r8, r8"); // callable descriptors leave the high value word empty + } PhpType::Str => { ctx.load_string_value_to_regs(value, "rax", "rdx")?; abi::emit_call_label(ctx.emitter, "__rt_str_persist"); @@ -1213,11 +1295,33 @@ fn materialize_hash_concrete_value_x86_64( Ok(()) } +/// Reports whether a successful hash lookup can materialize a value of this PHP type. +/// +/// Mirrors the arms of [`emit_hash_get_success_aarch64`] and its x86_64 twin. Hash storage has no +/// representation for the types this rejects, and `hash_set` has no arm for them either — so no +/// value of such a type can be inside a hash to begin with. Callers that emit a promoted-storage +/// read *speculatively* — an `Array(_)`-typed local may be hash-backed at runtime, so the read is +/// emitted behind a storage-kind branch — must gate on this: for an unrepresentable element type +/// the branch is not merely unreachable, it fails the whole compilation. `?int` (`TaggedScalar`) +/// is the case that exposed this. +pub(super) fn hash_get_supports_value_type(value_ty: &PhpType) -> bool { + matches!( + value_ty, + PhpType::Int + | PhpType::Bool + | PhpType::Callable + | PhpType::Float + | PhpType::Str + | PhpType::Mixed + ) || value_ty.is_refcounted() +} + /// Moves a successful AArch64 hash lookup payload into the canonical result registers. -fn emit_hash_get_success_aarch64( +pub(super) fn emit_hash_get_success_aarch64( ctx: &mut FunctionContext<'_>, value_ty: &PhpType, result_ty: &PhpType, + for_write: bool, ) -> Result<()> { match value_ty { PhpType::Int | PhpType::Bool | PhpType::Callable => { @@ -1234,7 +1338,7 @@ fn emit_hash_get_success_aarch64( } PhpType::Str => {} PhpType::Mixed => { - emit_hash_get_mixed_success_aarch64(ctx); + emit_hash_get_mixed_success_aarch64(ctx, for_write); } other if other.is_refcounted() => { ctx.emitter.instruction("mov x0, x1"); // return the borrowed pointer-backed hash payload @@ -1251,10 +1355,11 @@ fn emit_hash_get_success_aarch64( } /// Moves a successful x86_64 hash lookup payload into the canonical result registers. -fn emit_hash_get_success_x86_64( +pub(super) fn emit_hash_get_success_x86_64( ctx: &mut FunctionContext<'_>, value_ty: &PhpType, result_ty: &PhpType, + for_write: bool, ) -> Result<()> { match value_ty { PhpType::Int | PhpType::Bool | PhpType::Callable => { @@ -1274,7 +1379,7 @@ fn emit_hash_get_success_x86_64( ctx.emitter.instruction("mov rdx, rsi"); // move the borrowed hash string length into the paired string result } PhpType::Mixed => { - emit_hash_get_mixed_success_x86_64(ctx); + emit_hash_get_mixed_success_x86_64(ctx, for_write); } other if other.is_refcounted() => { ctx.emitter.instruction("mov rax, rdi"); // return the borrowed pointer-backed hash payload @@ -1291,13 +1396,17 @@ fn emit_hash_get_success_x86_64( } /// Materializes a successful AArch64 Mixed hash lookup as a boxed Mixed result. -fn emit_hash_get_mixed_success_aarch64(ctx: &mut FunctionContext<'_>) { +fn emit_hash_get_mixed_success_aarch64(ctx: &mut FunctionContext<'_>, for_write: bool) { let box_label = ctx.next_label("hash_get_mixed_box"); let done_label = ctx.next_label("hash_get_mixed_done"); ctx.emitter.instruction("cmp x3, #7"); // check whether the entry already stores a boxed Mixed cell ctx.emitter.instruction(&format!("b.ne {}", box_label)); // box concrete per-entry payloads before returning them as Mixed - ctx.emitter.instruction("mov x0, x1"); // return the boxed Mixed pointer stored in the hash entry - abi::emit_incref_if_refcounted(ctx.emitter, &PhpType::Mixed); + ctx.emitter.instruction("mov x0, x1"); // load the boxed Mixed pointer stored in the hash entry + if for_write { + abi::emit_incref_if_refcounted(ctx.emitter, &PhpType::Mixed); + } else { + abi::emit_call_label(ctx.emitter, "__rt_mixed_clone"); // detach values while preserving shared PHP resource identity + } ctx.emitter.instruction(&format!("b {}", done_label)); // skip on-demand boxing for already boxed entries ctx.emitter.label(&box_label); ctx.emitter.instruction("mov x0, x3"); // pass the concrete entry tag to the Mixed boxing helper @@ -1306,13 +1415,17 @@ fn emit_hash_get_mixed_success_aarch64(ctx: &mut FunctionContext<'_>) { } /// Materializes a successful x86_64 Mixed hash lookup as a boxed Mixed result. -fn emit_hash_get_mixed_success_x86_64(ctx: &mut FunctionContext<'_>) { +fn emit_hash_get_mixed_success_x86_64(ctx: &mut FunctionContext<'_>, for_write: bool) { let box_label = ctx.next_label("hash_get_mixed_box"); let done_label = ctx.next_label("hash_get_mixed_done"); ctx.emitter.instruction("cmp rcx, 7"); // check whether the entry already stores a boxed Mixed cell ctx.emitter.instruction(&format!("jne {}", box_label)); // box concrete per-entry payloads before returning them as Mixed - ctx.emitter.instruction("mov rax, rdi"); // return the boxed Mixed pointer stored in the hash entry - abi::emit_incref_if_refcounted(ctx.emitter, &PhpType::Mixed); + ctx.emitter.instruction("mov rax, rdi"); // load the boxed Mixed pointer stored in the hash entry + if for_write { + abi::emit_incref_if_refcounted(ctx.emitter, &PhpType::Mixed); + } else { + abi::emit_call_label(ctx.emitter, "__rt_mixed_clone"); // detach values while preserving shared PHP resource identity + } ctx.emitter.instruction(&format!("jmp {}", done_label)); // skip on-demand boxing for already boxed entries ctx.emitter.label(&box_label); ctx.emitter.instruction("mov rax, rcx"); // pass the concrete entry tag to the Mixed boxing helper @@ -1320,6 +1433,72 @@ fn emit_hash_get_mixed_success_x86_64(ctx: &mut FunctionContext<'_>) { ctx.emitter.label(&done_label); } +/// Returns an owned boxed hash entry for a nested write, promoting a typed entry in place first. +fn emit_hash_get_mixed_for_write_aarch64( + ctx: &mut FunctionContext<'_>, + hash: ValueId, + key: ValueId, +) -> Result<()> { + let boxed = ctx.next_label("hash_get_write_boxed"); + let done = ctx.next_label("hash_get_write_done"); + ctx.emitter.instruction("cmp x3, #7"); // does the hash already store a boxed Mixed cell? + ctx.emitter.instruction(&format!("b.eq {}", boxed)); // retain the existing cell without changing its identity + ctx.emitter.instruction("mov x0, x3"); // pass the typed runtime tag to the Mixed boxing helper + abi::emit_call_label(ctx.emitter, "__rt_mixed_from_value"); + abi::emit_call_label(ctx.emitter, "__rt_incref"); + abi::emit_push_reg(ctx.emitter, "x0"); + abi::emit_push_reg(ctx.emitter, "x0"); + materialize_hash_key_aarch64(ctx, key)?; + abi::emit_push_reg_pair(ctx.emitter, "x1", "x2"); + ctx.load_value_to_reg(hash, "x0")?; + abi::emit_pop_reg_pair(ctx.emitter, "x1", "x2"); + abi::emit_pop_reg(ctx.emitter, "x3"); + ctx.emitter.instruction("mov x4, xzr"); // boxed Mixed entries do not use a high payload word + ctx.emitter.instruction("mov x5, #7"); // runtime value tag 7 stores the promoted boxed cell + abi::emit_call_label(ctx.emitter, "__rt_hash_set"); + abi::emit_pop_reg(ctx.emitter, "x0"); + ctx.emitter.instruction(&format!("b {}", done)); // skip the already-boxed retain path + ctx.emitter.label(&boxed); + ctx.emitter.instruction("mov x0, x1"); // load the boxed Mixed pointer stored in the hash entry + abi::emit_incref_if_refcounted(ctx.emitter, &PhpType::Mixed); + ctx.emitter.label(&done); + Ok(()) +} + +/// Returns an owned boxed hash entry for a nested write on x86_64, promoting typed storage first. +fn emit_hash_get_mixed_for_write_x86_64( + ctx: &mut FunctionContext<'_>, + hash: ValueId, + key: ValueId, +) -> Result<()> { + let boxed = ctx.next_label("hash_get_write_boxed"); + let done = ctx.next_label("hash_get_write_done"); + ctx.emitter.instruction("cmp rcx, 7"); // does the hash already store a boxed Mixed cell? + ctx.emitter.instruction(&format!("je {}", boxed)); // retain the existing cell without changing its identity + ctx.emitter.instruction("mov rax, rcx"); // pass the typed runtime tag to the Mixed boxing helper + abi::emit_call_label(ctx.emitter, "__rt_mixed_from_value"); + abi::emit_push_reg(ctx.emitter, "rax"); + abi::emit_call_label(ctx.emitter, "__rt_incref"); + abi::emit_pop_reg(ctx.emitter, "rax"); + abi::emit_push_reg(ctx.emitter, "rax"); + abi::emit_push_reg(ctx.emitter, "rax"); + materialize_hash_key_x86_64(ctx, key)?; + abi::emit_push_reg_pair(ctx.emitter, "rsi", "rdx"); + ctx.load_value_to_reg(hash, "rdi")?; + abi::emit_pop_reg_pair(ctx.emitter, "rsi", "rdx"); + abi::emit_pop_reg(ctx.emitter, "rcx"); + ctx.emitter.instruction("xor r8, r8"); // boxed Mixed entries do not use a high payload word + ctx.emitter.instruction("mov r9, 7"); // runtime value tag 7 stores the promoted boxed cell + abi::emit_call_label(ctx.emitter, "__rt_hash_set"); + abi::emit_pop_reg(ctx.emitter, "rax"); + ctx.emitter.instruction(&format!("jmp {}", done)); // skip the already-boxed retain path + ctx.emitter.label(&boxed); + ctx.emitter.instruction("mov rax, rdi"); // load the boxed Mixed pointer stored in the hash entry + abi::emit_incref_if_refcounted(ctx.emitter, &PhpType::Mixed); + ctx.emitter.label(&done); + Ok(()) +} + /// Emits the miss fallback in the result shape expected by the associative-array value type. /// /// `miss_reads_as_null` is true for the *silent* read variants — the ones `??`, `isset()` and @@ -1554,7 +1733,14 @@ fn source_load_local_slot(ctx: &FunctionContext<'_>, value: ValueId) -> Result, inst: &In (PhpType::Mixed | PhpType::Union(_), PhpType::Void) => { lower_mixed_cell_runtime_assign(ctx, inst) } - (PhpType::Mixed | PhpType::Union(_), _) => lower_mixed_array_runtime_get(ctx, inst), + (PhpType::Mixed | PhpType::Union(_), _) => { + lower_mixed_array_runtime_get(ctx, inst, false) + } (PhpType::AssocArray { .. }, PhpType::Void) => hashes::lower_hash_append(ctx, inst), (other, _) => Err(CodegenIrError::unsupported(format!( "runtime_call with receiver PHP type {:?} returning PHP type {:?}", @@ -28,7 +30,11 @@ pub(super) fn lower_binary_runtime_call(ctx: &mut FunctionContext<'_>, inst: &In } /// Lowers `$mixed[$key]` through the shared boxed Mixed array/hash/stdClass reader. -pub(super) fn lower_mixed_array_runtime_get(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { +pub(super) fn lower_mixed_array_runtime_get( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, + for_write: bool, +) -> Result<()> { let receiver = expect_operand(inst, 0)?; let key = expect_operand(inst, 1)?; let warn_on_missing = expect_operand(inst, 2)?; @@ -44,7 +50,14 @@ pub(super) fn lower_mixed_array_runtime_get(ctx: &mut FunctionContext<'_>, inst: ctx.load_value_to_reg(receiver, "rdi")?; } } - abi::emit_call_label(ctx.emitter, "__rt_mixed_array_get"); + abi::emit_call_label( + ctx.emitter, + if for_write { + "__rt_mixed_array_get_for_write" + } else { + "__rt_mixed_array_get" + }, + ); cast_loaded_mixed_pointer_to_result(ctx, &inst.result_php_type.codegen_repr())?; store_if_result(ctx, inst) } @@ -179,4 +192,3 @@ pub(super) fn lower_mixed_array_runtime_set_x86_64( abi::emit_call_label(ctx.emitter, "__rt_mixed_array_set"); Ok(()) } - diff --git a/src/codegen/lower_inst/objects.rs b/src/codegen/lower_inst/objects.rs index 66c6ade7cd..40ceaeab43 100644 --- a/src/codegen/lower_inst/objects.rs +++ b/src/codegen/lower_inst/objects.rs @@ -21,22 +21,26 @@ use crate::codegen::platform::Arch; use crate::codegen::UNINITIALIZED_TYPED_PROPERTY_SENTINEL; use crate::codegen_support::sentinels::THROWABLE_CREATION_LINE_OFFSET; use crate::codegen::{ - abi, callable_descriptor, emit_box_current_value_as_mixed, runtime_value_tag, + abi, callable_descriptor, emit_box_current_owned_value_as_mixed, + emit_box_current_value_as_mixed, runtime_value_tag, }; use crate::intrinsics::IntrinsicCall; use crate::ir::{Immediate, Instruction, LocalSlotId, Op, ValueDef, ValueId}; use crate::names::{method_symbol, php_symbol_key}; +use crate::parser::ast::Visibility; use crate::types::{ClassInfo, InterfaceInfo, PhpType}; use super::super::context::FunctionContext; use super::{ builtins, callables, cast_loaded_mixed_pointer_to_result, direct_call_stack_pad_bytes, expect_data, - coerce_loaded_value_to_tagged_scalar, emit_loaded_assoc_array_to_mixed, + coerce_loaded_value_to_tagged_scalar, emit_instance_method_descriptor_entry_wrapper, + emit_loaded_assoc_array_to_mixed, emit_loaded_indexed_array_to_mixed, emit_mixed_string_for_persistent_store, emit_ref_arg_writebacks, expect_operand, iterators, load_value_to_first_int_arg, materialize_method_call_args_with_receiver_reg_and_refs, resolve_method_call_target, - property_values, store_if_result, store_method_call_result, + emit_runtime_callable_invoker_inline, property_values, store_if_result, + store_method_call_result, }; use crate::codegen::fibers; use crate::codegen::literal_defaults::{ @@ -98,6 +102,7 @@ struct ConstructorCallTarget { impl_class: String, param_types: Vec, ref_params: Vec, + sig: crate::types::FunctionSig, } @@ -108,6 +113,7 @@ mod throwable_new; mod fiber_dynamic_entry; mod dynamic_mixed_candidates; mod dynamic_factory; +mod dynamic_pdo; mod property_defaults; mod known_property_reads; mod mixed_property_reads; @@ -141,6 +147,8 @@ use dynamic_mixed_candidates::*; #[allow(unused_imports)] use dynamic_factory::*; #[allow(unused_imports)] +pub(in crate::codegen::lower_inst) use dynamic_pdo::*; +#[allow(unused_imports)] use property_defaults::*; #[allow(unused_imports)] use known_property_reads::*; diff --git a/src/codegen/lower_inst/objects/dynamic_factory.rs b/src/codegen/lower_inst/objects/dynamic_factory.rs index 40f47d2c72..1ec6bda577 100644 --- a/src/codegen/lower_inst/objects/dynamic_factory.rs +++ b/src/codegen/lower_inst/objects/dynamic_factory.rs @@ -48,7 +48,7 @@ pub(super) fn dynamic_new_candidates( continue; } if let Some(candidate) = - dynamic_new_candidate(ctx, class_name, class_info, arg_count, inst)? + dynamic_new_candidate(ctx, class_name, class_info, Some(arg_count), inst)? { candidates.push(candidate); } @@ -83,20 +83,22 @@ pub(super) fn dynamic_new_candidate( ctx: &FunctionContext<'_>, class_name: &str, class_info: &ClassInfo, - arg_count: usize, + arg_count: Option, inst: &Instruction, ) -> Result> { - if let Some(candidate) = - spl_runtime_storage_dynamic_new_candidate(class_name, class_info, arg_count) - { - return Ok(Some(candidate)); + if let Some(arg_count) = arg_count { + if let Some(candidate) = + spl_runtime_storage_dynamic_new_candidate(class_name, class_info, arg_count) + { + return Ok(Some(candidate)); + } } if class_interfaces_require_missing_method_symbols(ctx, class_name, class_info) { return Ok(None); } let constructor_key = php_symbol_key("__construct"); let constructor_impl = if let Some(constructor) = class_info.methods.get(&constructor_key) { - if constructor.params.len() != arg_count { + if arg_count.is_some_and(|arg_count| constructor.params.len() != arg_count) { return Ok(None); } let impl_class = class_info @@ -116,8 +118,9 @@ pub(super) fn dynamic_new_candidate( impl_class, param_types, ref_params: constructor.ref_params.clone(), + sig: constructor.clone(), }) - } else if arg_count == 0 { + } else if arg_count.is_none_or(|arg_count| arg_count == 0) { None } else { return Ok(None); diff --git a/src/codegen/lower_inst/objects/dynamic_mixed_candidates.rs b/src/codegen/lower_inst/objects/dynamic_mixed_candidates.rs index f0a26b5246..48ae6214ba 100644 --- a/src/codegen/lower_inst/objects/dynamic_mixed_candidates.rs +++ b/src/codegen/lower_inst/objects/dynamic_mixed_candidates.rs @@ -47,7 +47,7 @@ pub(super) fn emit_generic_dynamic_new_class_string( /// Returns AOT dynamic-new candidates in stable class-id order. pub(super) fn dynamic_new_mixed_candidates( ctx: &FunctionContext<'_>, - arg_count: usize, + arg_count: Option, inst: &Instruction, ) -> Result> { let mut candidates = Vec::new(); @@ -260,10 +260,11 @@ pub(super) fn emit_dynamic_new_mixed_candidate( ctx: &mut FunctionContext<'_>, candidate: &DynamicNewCandidate, constructor_args: &[ValueId], + constructor_arg_container: Option, dummy_receiver_operand: ValueId, result: ValueId, ) -> Result<()> { - if candidate.class_name == "SplFixedArray" { + if candidate.class_name == "SplFixedArray" && constructor_arg_container.is_none() { return emit_dynamic_new_mixed_spl_fixed_array_candidate( ctx, candidate.class_id, @@ -271,7 +272,9 @@ pub(super) fn emit_dynamic_new_mixed_candidate( result, ); } - if is_spl_doubly_linked_list_family(&candidate.class_name) { + if is_spl_doubly_linked_list_family(&candidate.class_name) + && constructor_arg_container.is_none() + { return emit_dynamic_new_mixed_spl_dll_candidate(ctx, candidate.class_id, result); } emit_object_allocation( @@ -290,17 +293,29 @@ pub(super) fn emit_dynamic_new_mixed_candidate( emit_property_default(ctx, object_base_reg, default)?; } if let Some(constructor) = &candidate.constructor_impl { - emit_dynamic_new_mixed_constructor_call( - ctx, - candidate, - constructor, - constructor_args, - dummy_receiver_operand, - )?; + if let Some(arg_container) = constructor_arg_container { + emit_dynamic_new_mixed_constructor_container_call( + ctx, + candidate, + constructor, + arg_container, + )?; + } else { + emit_dynamic_new_mixed_constructor_call( + ctx, + candidate, + constructor, + constructor_args, + dummy_receiver_operand, + )?; + } } abi::emit_load_temporary_stack_slot(ctx.emitter, object_reg, 0); abi::emit_release_temporary_stack(ctx.emitter, 16); - emit_box_current_value_as_mixed(ctx.emitter, &PhpType::Object(candidate.class_name.clone())); + emit_box_current_owned_value_as_mixed( + ctx.emitter, + &PhpType::Object(candidate.class_name.clone()), + ); ctx.store_result_value(result) } @@ -327,7 +342,10 @@ pub(super) fn emit_dynamic_new_without_constructor_mixed_candidate( } abi::emit_load_temporary_stack_slot(ctx.emitter, object_reg, 0); abi::emit_release_temporary_stack(ctx.emitter, 16); - emit_box_current_value_as_mixed(ctx.emitter, &PhpType::Object(candidate.class_name.clone())); + emit_box_current_owned_value_as_mixed( + ctx.emitter, + &PhpType::Object(candidate.class_name.clone()), + ); ctx.store_result_value(result) } @@ -362,7 +380,10 @@ pub(super) fn emit_dynamic_new_mixed_spl_fixed_array_candidate( abi::emit_load_int_immediate(ctx.emitter, abi::int_arg_reg_name(ctx.emitter.target, 1), 0); } abi::emit_call_label(ctx.emitter, "__rt_spl_fixed_new"); - emit_box_current_value_as_mixed(ctx.emitter, &PhpType::Object("SplFixedArray".to_string())); + emit_box_current_owned_value_as_mixed( + ctx.emitter, + &PhpType::Object("SplFixedArray".to_string()), + ); ctx.store_result_value(result) } @@ -378,7 +399,7 @@ pub(super) fn emit_dynamic_new_mixed_spl_dll_candidate( class_id as i64, ); abi::emit_call_label(ctx.emitter, "__rt_spl_dll_new"); - emit_box_current_value_as_mixed(ctx.emitter, &PhpType::Object(String::new())); + emit_box_current_owned_value_as_mixed(ctx.emitter, &PhpType::Object(String::new())); ctx.store_result_value(result) } @@ -421,6 +442,74 @@ pub(super) fn emit_dynamic_new_mixed_constructor_call( emit_ref_arg_writebacks(ctx, &call_args.ref_writebacks) } +/// Invokes a selected dynamic constructor through its uniform descriptor invoker when PHP +/// supplied named arguments or one or more spread arrays. +pub(super) fn emit_dynamic_new_mixed_constructor_container_call( + ctx: &mut FunctionContext<'_>, + candidate: &DynamicNewCandidate, + constructor: &ConstructorCallTarget, + arg_container: ValueId, +) -> Result<()> { + let receiver_ty = PhpType::Object(candidate.class_name.clone()); + let captures = vec![("receiver".to_string(), receiver_ty.clone(), false)]; + let constructor_key = php_symbol_key("__construct"); + let entry_label = emit_instance_method_descriptor_entry_wrapper( + ctx, + &constructor.impl_class, + &constructor_key, + &constructor.sig, + )?; + let invoker_label = emit_runtime_callable_invoker_inline(ctx, &constructor.sig, &captures); + let php_name = format!("{}::__construct", candidate.class_name); + let descriptor_label = callable_descriptor::static_descriptor_with_optional_invoker_meta( + ctx.data, + &entry_label, + Some(&php_name), + callable_descriptor::CALLABLE_DESC_KIND_FIRST_CLASS, + Some(&constructor.sig), + &captures, + &[], + callable_descriptor::CallableDescriptorInvocation::method( + callable_descriptor::CallableDescriptorShape::InstanceMethod, + Some(candidate.class_name.clone()), + "__construct", + ), + Some(&invoker_label), + ); + + let result_reg = abi::int_result_reg(ctx.emitter).to_string(); + let descriptor_reg = abi::nested_call_reg(ctx.emitter).to_string(); + let total_bytes = callable_descriptor::CALLABLE_DESC_RUNTIME_CAPTURE_OFFSET + 16; + abi::emit_load_temporary_stack_slot(ctx.emitter, &result_reg, 0); + abi::emit_incref_if_refcounted(ctx.emitter, &receiver_ty); + abi::emit_push_reg(ctx.emitter, &result_reg); + abi::emit_load_int_immediate(ctx.emitter, &result_reg, total_bytes as i64); + abi::emit_call_label(ctx.emitter, "__rt_heap_alloc"); + ctx.emitter + .instruction(&format!("mov {}, {}", descriptor_reg, result_reg)); // preserve the runtime constructor descriptor while copying its static header + callable_descriptor::emit_copy_static_descriptor_to_runtime( + ctx.emitter, + &descriptor_reg, + &descriptor_label, + ); + abi::emit_pop_reg(ctx.emitter, &result_reg); + callable_descriptor::emit_store_current_result_to_runtime_capture( + ctx.emitter, + &descriptor_reg, + 0, + &receiver_ty, + ); + callables::emit_descriptor_reg_invoker_mixed_result_with_arg_container( + ctx, + &descriptor_reg, + arg_container, + "dynamic_constructor", + true, + )?; + abi::emit_call_label(ctx.emitter, "__rt_decref_mixed"); + Ok(()) +} + /// Invokes the runtime class-name registry fallback and boxes a matched object as Mixed. pub(super) fn emit_dynamic_new_mixed_fallback(ctx: &mut FunctionContext<'_>) { let miss_label = ctx.next_label("dynamic_new_mixed_missing_class"); diff --git a/src/codegen/lower_inst/objects/dynamic_pdo.rs b/src/codegen/lower_inst/objects/dynamic_pdo.rs new file mode 100644 index 0000000000..5ea99f1d21 --- /dev/null +++ b/src/codegen/lower_inst/objects/dynamic_pdo.rs @@ -0,0 +1,339 @@ +//! Purpose: +//! Lowers dynamic PDO class classification, statement construction, and initialization opcodes. +//! +//! Called from: +//! - `crate::codegen::lower_inst::lower_instruction()` through builtin PDO hooks. +//! +//! Key details: +//! - Runtime class names are matched against AOT metadata in stable class-id order. +//! - PDO statement constructors may be non-public and receive named/spread argument containers. + +use super::*; + +/// Lowers the internal class-name predicate used to gate PDO's post-hydration constructor call. +pub(in crate::codegen::lower_inst) fn lower_dynamic_class_has_constructor( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + let class_name_value = expect_operand(inst, 0)?; + let false_label = ctx.next_label("dynamic_has_ctor_false"); + let done_label = ctx.next_label("dynamic_has_ctor_done"); + if !emit_generic_dynamic_new_class_string(ctx, class_name_value, &false_label)? { + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), 0); + return store_if_result(ctx, inst); + } + abi::emit_push_result_value(ctx.emitter, &PhpType::Str); + + let constructor_key = php_symbol_key("__construct"); + let mut classes = ctx + .module + .class_infos + .iter() + .filter(|(_, info)| info.methods.contains_key(&constructor_key)) + .collect::>(); + classes.sort_by_key(|(_, info)| info.class_id); + let matched_labels = classes + .iter() + .map(|(class_name, _)| { + let label = ctx.next_label("dynamic_has_ctor_match"); + emit_branch_if_dynamic_new_mixed_class_name_matches(ctx, class_name, &label); + label + }) + .collect::>(); + + abi::emit_release_temporary_stack(ctx.emitter, 16); + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), 0); + abi::emit_jump(ctx.emitter, &done_label); + for label in matched_labels { + ctx.emitter.label(&label); + abi::emit_release_temporary_stack(ctx.emitter, 16); + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), 1); + abi::emit_jump(ctx.emitter, &done_label); + } + ctx.emitter.label(&false_label); + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), 0); + ctx.emitter.label(&done_label); + store_if_result(ctx, inst) +} + +/// Classifies a runtime class name for PDO's `ATTR_STATEMENT_CLASS` contract. +/// +/// Status 0 is an unknown class, 1 is a known class outside the PDOStatement hierarchy, +/// 2 is a PDOStatement subclass with a public user constructor, 3/4 are valid concrete/abstract +/// classes without a user constructor, and 5/6 are valid concrete/abstract classes with a +/// non-public user constructor. PHP accepts abstract statuses when setting the attribute and +/// rejects them only when `prepare()` attempts instantiation. +pub(in crate::codegen::lower_inst) fn lower_dynamic_pdo_statement_class_status( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + let class_name_value = expect_operand(inst, 0)?; + let unknown_label = ctx.next_label("pdo_statement_class_unknown"); + let done_label = ctx.next_label("pdo_statement_class_done"); + if !emit_generic_dynamic_new_class_string(ctx, class_name_value, &unknown_label)? { + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), 0); + return store_if_result(ctx, inst); + } + abi::emit_push_result_value(ctx.emitter, &PhpType::Str); + + let mut classes = ctx.module.class_infos.iter().collect::>(); + classes.sort_by_key(|(_, info)| info.class_id); + let classified = classes + .into_iter() + .map(|(class_name, info)| { + let user_constructor = pdo_statement_user_constructor(ctx, class_name); + let has_user_constructor = user_constructor.is_some(); + let status = if !class_extends_class(ctx, class_name, "PDOStatement") { + 1 + } else if has_user_constructor + && user_constructor + .as_ref() + .is_some_and(|(_, visibility, _)| visibility == &Visibility::Public) + { + 2 + } else if info.is_abstract { + if has_user_constructor { 6 } else { 4 } + } else if has_user_constructor { + 5 + } else { + 3 + }; + let label = ctx.next_label("pdo_statement_class_match"); + emit_branch_if_dynamic_new_mixed_class_name_matches(ctx, class_name, &label); + (label, status) + }) + .collect::>(); + + abi::emit_release_temporary_stack(ctx.emitter, 16); + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), 0); + abi::emit_jump(ctx.emitter, &done_label); + for (label, status) in classified { + ctx.emitter.label(&label); + abi::emit_release_temporary_stack(ctx.emitter, 16); + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), status); + abi::emit_jump(ctx.emitter, &done_label); + } + ctx.emitter.label(&unknown_label); + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), 0); + ctx.emitter.label(&done_label); + store_if_result(ctx, inst) +} + +/// Classifies a runtime class name for PHP 8.4+'s late-static `PDO::connect()`. +/// +/// Status 0 is exactly PDO, 1..=7 are the SQLite/MySQL/PostgreSQL/DBLIB/ +/// Firebird/ODBC/IBM driver hierarchies, 8 is a generic PDO subclass, and 9 is unknown. +pub(in crate::codegen::lower_inst) fn lower_dynamic_pdo_called_class_status( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + let class_name_value = expect_operand(inst, 0)?; + let unknown_label = ctx.next_label("pdo_called_class_unknown"); + let done_label = ctx.next_label("pdo_called_class_done"); + if !emit_generic_dynamic_new_class_string(ctx, class_name_value, &unknown_label)? { + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), 9); + return store_if_result(ctx, inst); + } + abi::emit_push_result_value(ctx.emitter, &PhpType::Str); + + let mut classes = ctx.module.class_infos.iter().collect::>(); + classes.sort_by_key(|(_, info)| info.class_id); + let classified = classes + .into_iter() + .map(|(class_name, _)| { + let status = if same_php_type_name(class_name, "PDO") { + 0 + } else if class_extends_class(ctx, class_name, "Pdo\\Sqlite") { + 1 + } else if class_extends_class(ctx, class_name, "Pdo\\Mysql") { + 2 + } else if class_extends_class(ctx, class_name, "Pdo\\Pgsql") { + 3 + } else if class_extends_class(ctx, class_name, "Pdo\\Dblib") { + 4 + } else if class_extends_class(ctx, class_name, "Pdo\\Firebird") { + 5 + } else if class_extends_class(ctx, class_name, "Pdo\\Odbc") { + 6 + } else if class_extends_class(ctx, class_name, "Pdo\\Ibm") { + 7 + } else if class_extends_class(ctx, class_name, "PDO") { + 8 + } else { + 9 + }; + let label = ctx.next_label("pdo_called_class_match"); + emit_branch_if_dynamic_new_mixed_class_name_matches(ctx, class_name, &label); + (label, status) + }) + .collect::>(); + + abi::emit_release_temporary_stack(ctx.emitter, 16); + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), 9); + abi::emit_jump(ctx.emitter, &done_label); + for (label, status) in classified { + ctx.emitter.label(&label); + abi::emit_release_temporary_stack(ctx.emitter, 16); + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), status); + abi::emit_jump(ctx.emitter, &done_label); + } + ctx.emitter.label(&unknown_label); + abi::emit_load_int_immediate(ctx.emitter, abi::int_result_reg(ctx.emitter), 9); + ctx.emitter.label(&done_label); + store_if_result(ctx, inst) +} + +/// Invokes the protected/private constructor selected by PDO statement-class metadata. +pub(in crate::codegen::lower_inst) fn lower_dynamic_pdo_statement_constructor_call( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + let class_name_value = expect_operand(inst, 0)?; + let statement_value = expect_operand(inst, 1)?; + let argument_container = expect_operand(inst, 2)?; + let unmatched_label = ctx.next_label("pdo_statement_constructor_unmatched"); + let done_label = ctx.next_label("pdo_statement_constructor_done"); + if !emit_generic_dynamic_new_class_string(ctx, class_name_value, &unmatched_label)? { + emit_void_sentinel(ctx); + return store_if_result(ctx, inst); + } + abi::emit_push_result_value(ctx.emitter, &PhpType::Str); + + let mut candidates = Vec::new(); + let mut classes = ctx.module.class_infos.iter().collect::>(); + classes.sort_by_key(|(_, info)| info.class_id); + for (class_name, info) in classes { + let Some((owner, visibility, signature)) = + pdo_statement_user_constructor(ctx, class_name) + else { + continue; + }; + if !class_extends_class(ctx, class_name, "PDOStatement") + || info.is_abstract + || visibility == Visibility::Public + { + continue; + } + let Some(mut candidate) = dynamic_new_candidate(ctx, class_name, info, None, inst)? else { + continue; + }; + candidate.constructor_impl = Some(ConstructorCallTarget { + impl_class: owner, + param_types: signature + .params + .iter() + .map(|(_, ty)| ty.codegen_repr()) + .collect(), + ref_params: signature.ref_params.clone(), + sig: signature, + }); + let label = ctx.next_label("pdo_statement_constructor_match"); + emit_branch_if_dynamic_new_mixed_class_name_matches(ctx, class_name, &label); + candidates.push((candidate, label)); + } + abi::emit_release_temporary_stack(ctx.emitter, 16); + abi::emit_jump(ctx.emitter, &done_label); + + for (candidate, label) in candidates { + ctx.emitter.label(&label); + abi::emit_release_temporary_stack(ctx.emitter, 16); + ctx.load_value_to_reg(statement_value, abi::int_result_reg(ctx.emitter))?; + abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); + move_mixed_unboxed_object_payload(ctx, abi::int_result_reg(ctx.emitter)); + abi::emit_push_reg(ctx.emitter, abi::int_result_reg(ctx.emitter)); + let constructor = candidate.constructor_impl.as_ref().ok_or_else(|| { + CodegenIrError::invalid_module("PDO statement constructor candidate has no constructor") + })?; + emit_dynamic_new_mixed_constructor_container_call( + ctx, + &candidate, + constructor, + argument_container, + )?; + abi::emit_jump(ctx.emitter, &done_label); + } + + ctx.emitter.label(&unmatched_label); + ctx.emitter.label(&done_label); + emit_void_sentinel(ctx); + store_if_result(ctx, inst) +} + +/// Materializes the EIR void sentinel after an internal side-effect-only constructor call. +fn emit_void_sentinel(ctx: &mut FunctionContext<'_>) { + abi::emit_load_int_immediate( + ctx.emitter, + abi::int_result_reg(ctx.emitter), + RUNTIME_NULL_SENTINEL, + ); +} + +/// Resolves PDO's effective user constructor, including private ancestor constructors retained by +/// php-src for `ATTR_STATEMENT_CLASS` even though ordinary PHP inheritance omits them. +fn pdo_statement_user_constructor( + ctx: &FunctionContext<'_>, + class_name: &str, +) -> Option<(String, Visibility, crate::types::FunctionSig)> { + let constructor_key = php_symbol_key("__construct"); + let mut current = Some(class_name.to_string()); + while let Some(name) = current { + let info = class_info_by_name(ctx, &name)?; + if let Some(signature) = info.methods.get(&constructor_key) { + let owner = info + .method_impl_classes + .get(&constructor_key) + .cloned() + .unwrap_or_else(|| name.clone()); + if same_php_type_name(&owner, "PDOStatement") { + return None; + } + let visibility = info + .method_visibilities + .get(&constructor_key) + .cloned() + .unwrap_or(Visibility::Public); + return Some((owner, visibility, signature.clone())); + } + current = info.parent.clone(); + } + None +} + +/// Calls PDOStatement's private base initializer on an already allocated subclass object. +pub(in crate::codegen::lower_inst) fn lower_dynamic_pdo_statement_initialize( + ctx: &mut FunctionContext<'_>, + inst: &Instruction, +) -> Result<()> { + let statement = expect_operand(inst, 0)?; + ctx.load_value_to_reg(statement, abi::int_result_reg(ctx.emitter))?; + abi::emit_call_label(ctx.emitter, "__rt_mixed_unbox"); + move_mixed_unboxed_object_payload(ctx, abi::int_result_reg(ctx.emitter)); + let receiver_reg = abi::int_result_reg(ctx.emitter).to_string(); + let params = [ + PhpType::Object("PDOStatement".to_string()), + PhpType::Int, + PhpType::Int, + PhpType::Int, + PhpType::Str, + ]; + let refs = [false, false, false, false, false]; + let call_args = materialize_method_call_args_with_receiver_reg_and_refs( + ctx, + &receiver_reg, + ¶ms[0], + &inst.operands, + ¶ms, + &refs, + )?; + let caller_stack_pad_bytes = direct_call_stack_pad_bytes(ctx, call_args.overflow_bytes); + abi::emit_reserve_temporary_stack(ctx.emitter, caller_stack_pad_bytes); + abi::emit_call_label( + ctx.emitter, + &method_symbol("PDOStatement", &php_symbol_key("__elephcInitialize")), + ); + abi::emit_release_temporary_stack(ctx.emitter, caller_stack_pad_bytes); + abi::emit_release_temporary_stack(ctx.emitter, call_args.overflow_bytes); + emit_ref_arg_writebacks(ctx, &call_args.ref_writebacks)?; + emit_void_sentinel(ctx); + store_if_result(ctx, inst) +} diff --git a/src/codegen/lower_inst/objects/fiber_dynamic_entry.rs b/src/codegen/lower_inst/objects/fiber_dynamic_entry.rs index e523b7a595..70364f6d89 100644 --- a/src/codegen/lower_inst/objects/fiber_dynamic_entry.rs +++ b/src/codegen/lower_inst/objects/fiber_dynamic_entry.rs @@ -141,9 +141,23 @@ pub(in crate::codegen::lower_inst) fn lower_dynamic_object_new_mixed( inst: &Instruction, ) -> Result<()> { let class_name_value = expect_operand(inst, 0)?; - let constructor_args = inst.operands.get(1..).ok_or_else(|| { - CodegenIrError::invalid_module("dynamic_object_new_mixed missing class operand") - })?; + let uses_runtime_arg_container = matches!(inst.immediate, Some(Immediate::Bool(true))); + let constructor_arg_container = if uses_runtime_arg_container { + Some(*inst.operands.get(1).ok_or_else(|| { + CodegenIrError::invalid_module( + "dynamic_object_new_mixed missing runtime constructor argument container", + ) + })?) + } else { + None + }; + let constructor_args = if uses_runtime_arg_container { + &inst.operands[0..0] + } else { + inst.operands.get(1..).ok_or_else(|| { + CodegenIrError::invalid_module("dynamic_object_new_mixed missing class operand") + })? + }; let result = inst.result.ok_or_else(|| { CodegenIrError::invalid_module("dynamic_object_new_mixed missing result value") })?; @@ -156,7 +170,11 @@ pub(in crate::codegen::lower_inst) fn lower_dynamic_object_new_mixed( abi::emit_push_result_value(ctx.emitter, &PhpType::Str); let fallback_label = ctx.next_label("dynamic_new_mixed_fallback"); - let candidates = dynamic_new_mixed_candidates(ctx, constructor_args.len(), inst)?; + let candidates = dynamic_new_mixed_candidates( + ctx, + (!uses_runtime_arg_container).then_some(constructor_args.len()), + inst, + )?; let case_labels = candidates .iter() .map(|candidate| { @@ -174,6 +192,7 @@ pub(in crate::codegen::lower_inst) fn lower_dynamic_object_new_mixed( ctx, candidate, constructor_args, + constructor_arg_container, class_name_value, result, )?; diff --git a/src/codegen/lower_inst/objects/fixed_new.rs b/src/codegen/lower_inst/objects/fixed_new.rs index 09393d672a..7d67c19d16 100644 --- a/src/codegen/lower_inst/objects/fixed_new.rs +++ b/src/codegen/lower_inst/objects/fixed_new.rs @@ -86,6 +86,7 @@ pub(in crate::codegen::lower_inst) fn lower_object_new(ctx: &mut FunctionContext impl_class, param_types, ref_params: constructor.ref_params.clone(), + sig: constructor.clone(), }) } else if !inst.operands.is_empty() { return Err(CodegenIrError::unsupported(format!( diff --git a/src/codegen/lower_inst/objects/property_store_values.rs b/src/codegen/lower_inst/objects/property_store_values.rs index 7c4204f866..37a2f042f1 100644 --- a/src/codegen/lower_inst/objects/property_store_values.rs +++ b/src/codegen/lower_inst/objects/property_store_values.rs @@ -25,43 +25,47 @@ pub(super) fn load_property_store_value_to_result( } if can_store_boxed_value_for_mixed_property(&value_ty, slot_ty) { ctx.load_value_to_result(value)?; + // Transfer an unreleased owning box into the property; retain borrowed values and + // temporaries whose explicit EIR cleanup still owns the source reference. if !ctx.value_can_own_mixed_box_source(value)? { abi::emit_incref_if_refcounted(ctx.emitter, &value_ty); } return Ok(()); } if can_convert_indexed_array_to_mixed_property(&value_ty, slot_ty) { - let PhpType::Array(source_elem) = ctx.load_value_to_result(value)?.codegen_repr() else { + let loaded_ty = ctx.load_value_to_result(value)?.codegen_repr(); + let PhpType::Array(source_elem) = &loaded_ty else { return Err(CodegenIrError::unsupported(format!( "property array widening from PHP type {:?}", value_ty ))); }; + // Give the conversion helper an owned candidate. Its COW split consumes that retain + // while leaving the SSA source untouched, and the returned unique array transfers + // directly into the property slot. + abi::emit_incref_if_refcounted(ctx.emitter, &loaded_ty); emit_loaded_indexed_array_to_mixed(ctx, &source_elem.codegen_repr()); - abi::emit_incref_if_refcounted(ctx.emitter, &PhpType::Array(Box::new(PhpType::Mixed))); return Ok(()); } if can_store_assoc_array_as_mixed_property(&value_ty, slot_ty) { + let loaded_ty = ctx.load_value_to_result(value)?.codegen_repr(); let PhpType::AssocArray { - key: source_key, value: source_value, - } = ctx.load_value_to_result(value)?.codegen_repr() + .. + } = &loaded_ty else { return Err(CodegenIrError::unsupported(format!( "property associative-array widening from PHP type {:?}", value_ty ))); }; + // Retain before a possible COW conversion so `PropSet` never consumes the SSA source. + // The retained value itself is the property owner when the hash already stores Mixed + // entries. + abi::emit_incref_if_refcounted(ctx.emitter, &loaded_ty); if source_value.codegen_repr() != PhpType::Mixed { emit_loaded_assoc_array_to_mixed(ctx); } - abi::emit_incref_if_refcounted( - ctx.emitter, - &PhpType::AssocArray { - key: source_key, - value: Box::new(PhpType::Mixed), - }, - ); return Ok(()); } if can_store_value_as_tagged_scalar_property(&value_ty, slot_ty) { diff --git a/src/codegen/lower_inst/reference_arguments.rs b/src/codegen/lower_inst/reference_arguments.rs index c0e9f2bc74..eb18d77a0d 100644 --- a/src/codegen/lower_inst/reference_arguments.rs +++ b/src/codegen/lower_inst/reference_arguments.rs @@ -347,6 +347,19 @@ pub(super) fn emit_unbox_mixed_to_owned_refcounted_result(ctx: &mut FunctionCont abi::emit_incref_if_refcounted(ctx.emitter, result_ty); } +/// Unboxes a guarded Mixed value into an owned concrete heap representation. +/// +/// Flow-sensitive checking proves the value has the requested type before this op is emitted; +/// the runtime helper extracts its payload and this result takes its own reference so retaining +/// stores and later cleanup have a balanced ownership ledger. +pub(super) fn lower_mixed_unbox(ctx: &mut FunctionContext<'_>, inst: &Instruction) -> Result<()> { + let value = expect_operand(inst, 0)?; + load_value_to_first_int_arg(ctx, value)?; + let result_ty = inst.result_php_type.codegen_repr(); + emit_unbox_mixed_to_owned_refcounted_result(ctx, &result_ty); + store_if_result(ctx, inst) +} + /// Stores an unboxed scalar Mixed payload back through the original by-reference source. pub(super) fn store_current_scalar_result_to_ref_source( ctx: &mut FunctionContext<'_>, diff --git a/src/codegen/lower_inst/throwable_methods.rs b/src/codegen/lower_inst/throwable_methods.rs index d7f048ef13..6225348f86 100644 --- a/src/codegen/lower_inst/throwable_methods.rs +++ b/src/codegen/lower_inst/throwable_methods.rs @@ -10,12 +10,32 @@ use super::*; /// Returns true when a direct method call can be satisfied from the compact Throwable payload. +/// +/// PDOException and its subclasses keep `getCode()` and `getPrevious()` on their PHP +/// implementations because those values live outside the compiler-owned base payload. pub(super) fn is_throwable_standard_method_call( ctx: &FunctionContext<'_>, class_name: &str, method_name: &str, ) -> bool { - is_throwable_standard_method_key(&php_symbol_key(method_name)) + let method_key = php_symbol_key(method_name); + let mut current = Some(class_name.trim_start_matches('\\')); + let mut pdo_exception_receiver = false; + while let Some(name) = current { + if name == "PDOException" { + pdo_exception_receiver = true; + break; + } + current = ctx + .module + .class_infos + .get(name) + .and_then(|info| info.parent.as_deref()); + } + if pdo_exception_receiver && matches!(method_key.as_str(), "getcode" | "getprevious") { + return false; + } + is_throwable_standard_method_key(&method_key) && is_throwable_like_class(ctx, class_name) } @@ -304,4 +324,3 @@ pub(super) fn lower_throwable_get_previous( Ok(object_ty) } } - diff --git a/src/codegen/runtime_metadata/dynamic_references.rs b/src/codegen/runtime_metadata/dynamic_references.rs index dd54d50029..6c617ce91d 100644 --- a/src/codegen/runtime_metadata/dynamic_references.rs +++ b/src/codegen/runtime_metadata/dynamic_references.rs @@ -54,7 +54,10 @@ pub(in crate::codegen) fn referenced_dynamic_object_new_class_names(module: &Mod .chain(module.runtime_callable_invokers.iter()) { for inst in &function.instructions { - if matches!(inst.op, Op::DynamicObjectNewMixed) { + if matches!( + inst.op, + Op::DynamicObjectNewMixed | Op::DynamicObjectNewWithoutConstructorMixed + ) { names.extend( module .class_infos diff --git a/src/codegen/web.rs b/src/codegen/web.rs index cf1f161b01..fac8e59475 100644 --- a/src/codegen/web.rs +++ b/src/codegen/web.rs @@ -103,10 +103,35 @@ pub(super) fn emit_web_reset(emitter: &mut Emitter, module: &Module, data: &Data emit_concat_offset_reset(emitter); + // The heap arena reset MUST be the final reset step: the static/global releases + // above may run destructors or decref shared values, which require the arena to + // still be valid. Wiping the allocator to pure-bump state last reclaims the whole + // per-request arena at once. This is coupled to `--web` full-reset semantics; a + // future persistent-statics worker-script mode (`--web-worker`, PR #456) must NOT + // route through this routine, or its surviving statics would be freed underneath it. + emit_heap_arena_reset(emitter); + abi::emit_frame_restore(emitter, RESET_FRAME_SIZE); abi::emit_return(emitter); } +/// Resets the PHP heap arena to a pristine bump-only state: `_heap_off = 0`, an empty +/// ordered free list, and empty small-bin caches. Emitted as the final step of the +/// per-request `__rt_web_reset`, so the whole arena is reclaimed at once after every +/// refcounted per-request value has already been released above. Valid only under +/// `--web` full-reset semantics — nothing in the PHP arena legitimately survives a +/// request; Rust-side state (the PDO persistent-connection pool, bridge result cells) +/// lives outside `_heap_buf` and is unaffected. +fn emit_heap_arena_reset(emitter: &mut Emitter) { + emitter.comment("reset the PHP heap arena to pure-bump allocation for the next request"); + abi::emit_store_zero_to_symbol(emitter, "_heap_off", 0); + abi::emit_store_zero_to_symbol(emitter, "_heap_free_list", 0); + abi::emit_store_zero_to_symbol(emitter, "_heap_small_bins", 0); + abi::emit_store_zero_to_symbol(emitter, "_heap_small_bins", 8); + abi::emit_store_zero_to_symbol(emitter, "_heap_small_bins", 16); + abi::emit_store_zero_to_symbol(emitter, "_heap_small_bins", 24); +} + /// Resets one function static local: skips uninitialized slots, releases any /// owned refcounted value, then zeroes the 16-byte value and the init marker so /// the static's initializer re-runs on the next request. diff --git a/src/codegen_support/abi/bootstrap.rs b/src/codegen_support/abi/bootstrap.rs index b8b7c6fc46..56033bc64c 100644 --- a/src/codegen_support/abi/bootstrap.rs +++ b/src/codegen_support/abi/bootstrap.rs @@ -28,6 +28,19 @@ pub fn emit_enable_heap_debug_flag(emitter: &mut Emitter) { emit_store_reg_to_symbol(emitter, scratch, "_heap_debug_enabled", 0); } +/// Set the web heap-guard flag to 1 in global symbol storage. +/// +/// Enables the cheap small-bin double-free detection in `--web` builds without the +/// expensive per-allocation free-list validation that `--heap-debug` also turns on. +/// A detected double free routes to `__rt_heap_debug_fail`, which writes the diagnostic +/// and `_exit(1)`s the worker so the prefork master respawns it, containing the +/// corruption to a single request rather than aborting the whole server. +pub fn emit_enable_web_heap_guard_flag(emitter: &mut Emitter) { + let scratch = temp_int_reg(emitter.target); + emit_load_int_immediate(emitter, scratch, 1); + emit_store_reg_to_symbol(emitter, scratch, "_web_heap_guard_enabled", 0); +} + /// Copy the current frame pointer into the destination scratch register. #[cfg(test)] pub fn emit_copy_frame_pointer(emitter: &mut Emitter, dest: &str) { @@ -44,8 +57,9 @@ pub fn emit_copy_frame_pointer(emitter: &mut Emitter, dest: &str) { /// - `code`: the exit code visible to the OS; must fit in the target's exit register. /// /// # Platform behavior -/// - **macOS ARM64 / Linux ARM64**: loads `code` into `x0` and invokes syscall 1 (`sys_exit`). -/// - **Linux x86_64**: loads `code` into `edi` (SysV first-argument register) and invokes syscall 60 (`exit`). +/// - **macOS ARM64**: loads `code` into `x0` and invokes syscall 1 (`sys_exit`). +/// - **Linux ARM64**: loads `code` into `x0` and invokes syscall 94 (`exit_group`). +/// - **Linux x86_64**: loads `code` into `edi` and invokes syscall 231 (`exit_group`). /// - **macOS x86_64**: panics — not yet implemented. /// /// This routine never returns to the calling code. The syscall consumes the current execution context. @@ -61,7 +75,7 @@ pub fn emit_exit(emitter: &mut Emitter, code: u32) { emitter.instruction("and rsp, -16"); // realign the stack for the flush call (this path never returns) emitter.instruction("call __rt_ob_flush_all"); // drain still-active output buffers to stdout before terminating emitter.instruction(&format!("mov edi, {}", code)); // load the requested process exit code into the SysV first-argument register - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall 60 = exit + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 = exit_group emitter.instruction("syscall"); // terminate the process through the Linux x86_64 syscall ABI } (super::super::platform::Platform::MacOS, Arch::X86_64) => { @@ -81,10 +95,10 @@ pub fn emit_exit(emitter: &mut Emitter, code: u32) { /// return value as the process exit code. /// /// # Platform behavior -/// - **macOS ARM64 / Linux ARM64**: the return value already sits in `x0`, which -/// is `sys_exit`'s argument register, so it invokes syscall 1 directly. +/// - **macOS ARM64 / Linux ARM64**: the return value already sits in `x0`; the +/// target invokes `sys_exit` on macOS or `exit_group` on Linux. /// - **Linux x86_64**: moves `eax` (the C return value) into `edi` (the SysV exit -/// argument) and invokes syscall 60 (`exit`). +/// argument) and invokes syscall 231 (`exit_group`). /// - **macOS x86_64**: panics — not in the supported target matrix. /// /// This routine never returns to the calling code. @@ -102,7 +116,7 @@ pub fn emit_exit_with_result_reg(emitter: &mut Emitter) { emitter.instruction("and rsp, -16"); // realign the stack for the flush call (this path never returns) emitter.instruction("call __rt_ob_flush_all"); // drain still-active output buffers to stdout before terminating emitter.instruction("mov edi, ebx"); // move the stashed return value into the SysV exit argument register - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall 60 = exit + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 = exit_group emitter.instruction("syscall"); // terminate the process with the bridge return code } (super::super::platform::Platform::MacOS, Arch::X86_64) => { diff --git a/src/codegen_support/abi/calls/mod.rs b/src/codegen_support/abi/calls/mod.rs index 60e968a233..72d42c5047 100644 --- a/src/codegen_support/abi/calls/mod.rs +++ b/src/codegen_support/abi/calls/mod.rs @@ -17,7 +17,7 @@ pub use incoming::emit_store_incoming_param; pub use invoke::{emit_call_label, emit_call_reg}; pub use outgoing::{ build_outgoing_arg_assignments_for_target, materialize_outgoing_args, - outgoing_call_stack_pad_bytes, + materialize_outgoing_c_abi_args, outgoing_call_stack_pad_bytes, }; pub use stack::{ emit_load_temporary_stack_slot, emit_pop_float_reg, emit_pop_reg, emit_pop_reg_pair, diff --git a/src/codegen_support/abi/calls/outgoing.rs b/src/codegen_support/abi/calls/outgoing.rs index 6fb917ad41..ffe64366bc 100644 --- a/src/codegen_support/abi/calls/outgoing.rs +++ b/src/codegen_support/abi/calls/outgoing.rs @@ -7,6 +7,8 @@ //! //! Key details: //! - Stack reservation and register ordering must preserve live values while matching target ABI limits. +//! - Elephc calls retain 16-byte stack slots, while C ABI overflow scalars are +//! packed into 8-byte words inside a 16-byte-aligned outgoing area. use crate::codegen_support::{ emit::Emitter, @@ -22,6 +24,15 @@ use super::super::registers::{ }; use super::stack::{emit_load_temporary_stack_slot, emit_store_to_sp}; +/// Selects the caller-stack layout used for arguments that overflow registers. +#[derive(Clone, Copy)] +enum OutgoingStackLayout { + /// Elephc's generated-function ABI uses one 16-byte slot per argument. + Elephc, + /// The platform C ABI packs each supported scalar extern argument into one word. + C, +} + /// Plans register and stack assignment for each outgoing call argument. /// /// Traverses `arg_types` in order, assigning registers until the target's integer or float @@ -93,6 +104,20 @@ fn arg_slot_size(ty: &PhpType) -> usize { } } +/// Returns the caller-stack bytes occupied by one overflow argument in `layout`. +fn outgoing_arg_slot_size(ty: &PhpType, layout: OutgoingStackLayout) -> usize { + match (layout, ty) { + (_, PhpType::Void) => 0, + (OutgoingStackLayout::Elephc, _) => 16, + (OutgoingStackLayout::C, _) => 8, + } +} + +/// Rounds a non-empty outgoing stack area up to the platform-required 16-byte alignment. +fn align_outgoing_stack_bytes(bytes: usize) -> usize { + (bytes + 15) & !15 +} + /// Copies one argument slot from `src_offset` (temporary stack) to `dst_offset` (SP-based /// outgoing area) using scratch registers. /// @@ -150,16 +175,41 @@ pub fn materialize_outgoing_args( emitter: &mut Emitter, assignments: &[OutgoingArgAssignment], ) -> usize { - let slot_sizes: Vec = assignments + materialize_outgoing_args_with_layout(emitter, assignments, OutgoingStackLayout::Elephc) +} + +/// Materializes pre-evaluated scalar extern arguments using the platform C ABI stack layout. +/// +/// The temporary evaluation stack still uses elephc's 16-byte slots, but overflow +/// arguments are packed into consecutive 8-byte C words and the final area is padded +/// to preserve the target's 16-byte call-site stack alignment. +pub fn materialize_outgoing_c_abi_args( + emitter: &mut Emitter, + assignments: &[OutgoingArgAssignment], +) -> usize { + materialize_outgoing_args_with_layout(emitter, assignments, OutgoingStackLayout::C) +} + +/// Copies pre-evaluated arguments into registers and the selected caller-stack layout. +fn materialize_outgoing_args_with_layout( + emitter: &mut Emitter, + assignments: &[OutgoingArgAssignment], + layout: OutgoingStackLayout, +) -> usize { + let temp_slot_sizes: Vec = assignments .iter() .map(|assignment| arg_slot_size(&assignment.ty)) .collect(); - let total_temp_bytes: usize = slot_sizes.iter().sum(); + let outgoing_slot_sizes: Vec = assignments + .iter() + .map(|assignment| outgoing_arg_slot_size(&assignment.ty, layout)) + .collect(); + let total_temp_bytes: usize = temp_slot_sizes.iter().sum(); let mut temp_offsets = vec![0usize; assignments.len()]; let mut running_offset = 0usize; for i in (0..assignments.len()).rev() { temp_offsets[i] = running_offset; - running_offset += slot_sizes[i]; + running_offset += temp_slot_sizes[i]; } let overflow_indices: Vec = assignments @@ -167,7 +217,11 @@ pub fn materialize_outgoing_args( .enumerate() .filter_map(|(idx, assignment)| (!assignment.in_register()).then_some(idx)) .collect(); - let overflow_bytes: usize = overflow_indices.iter().map(|idx| slot_sizes[*idx]).sum(); + let packed_overflow_bytes: usize = overflow_indices + .iter() + .map(|idx| outgoing_slot_sizes[*idx]) + .sum(); + let overflow_bytes = align_outgoing_stack_bytes(packed_overflow_bytes); let staging_bytes = overflow_bytes; let reserved_overflow_bytes = overflow_bytes + staging_bytes; @@ -230,7 +284,7 @@ pub fn materialize_outgoing_args( for idx in &overflow_indices { let src_offset = reserved_overflow_bytes + temp_offsets[*idx]; emit_copy_stack_arg_slot(emitter, &assignments[*idx].ty, src_offset, staging_offset); - staging_offset += slot_sizes[*idx]; + staging_offset += outgoing_slot_sizes[*idx]; } let final_stack_base = total_temp_bytes + staging_bytes; @@ -242,7 +296,7 @@ pub fn materialize_outgoing_args( staging_offset, final_stack_base + staging_offset, ); - staging_offset += slot_sizes[*idx]; + staging_offset += outgoing_slot_sizes[*idx]; } } diff --git a/src/codegen_support/abi/frame.rs b/src/codegen_support/abi/frame.rs index 88fa4c5b21..64b29ae212 100644 --- a/src/codegen_support/abi/frame.rs +++ b/src/codegen_support/abi/frame.rs @@ -55,8 +55,17 @@ pub fn emit_frame_prologue(emitter: &mut Emitter, frame_size: usize) { } /// Tears down the stack frame and restores the caller's frame state. -/// On AArch64: restores x29/x30 from the footer and releases `frame_size` bytes. -/// On x86_64: releases local bytes and pops rbp. +/// On AArch64: restores sp/x29/x30 from the frame pointer footer. On x86_64: restores +/// rsp from rbp and pops rbp (the `leave` idiom). +/// +/// Both restores are anchored on the frame pointer (x29/rbp) rather than computed by +/// adding `frame_size`/`local_bytes` back onto sp/rsp. The frame pointer is established +/// once at function entry by `emit_frame_prologue` and is never repurposed mid-body (it +/// is excluded from the register allocator's pools), so it stays reliable even when sp +/// itself has drifted from a mid-body cross-block spill imbalance — e.g. a push whose +/// matching pop was skipped on some taken control-flow path. Restoring through the frame +/// pointer corrects that drift instead of reproducing it into the caller; for an already +/// balanced body it yields the identical sp/rsp as the old size-based arithmetic. pub fn emit_frame_restore(emitter: &mut Emitter, frame_size: usize) { debug_assert!( frame_size >= 16, @@ -64,21 +73,14 @@ pub fn emit_frame_restore(emitter: &mut Emitter, frame_size: usize) { ); match emitter.target.arch { Arch::AArch64 => { - let footer_offset = frame_size - 16; - if footer_offset <= 504 { - emitter.instruction(&format!("ldp x29, x30, [sp, #{}]", footer_offset)); // restore frame pointer and return address from the fixed frame footer - } else { - emit_sp_address(emitter, "x9", footer_offset); - emitter.instruction("ldp x29, x30, [x9]"); // restore frame pointer and return address through the computed footer pointer - } - emit_adjust_sp(emitter, frame_size, false); + // x29 == entry_sp - 16, and [x29] is the saved frame footer regardless + // of any temporary-stack drift in the function body. + emitter.instruction("mov x9, x29"); // preserve the footer address before restoring the caller frame pointer + emitter.instruction("add sp, x9, #16"); // restore the entry stack pointer from the stable frame anchor + emitter.instruction("ldp x29, x30, [x9]"); // reload the caller frame pointer and return address } Arch::X86_64 => { - let local_bytes = frame_size.saturating_sub(16); - if local_bytes > 0 { - emitter.instruction(&format!("add rsp, {}", local_bytes)); // release the aligned local-slot area below rbp - } - emitter.instruction("pop rbp"); // restore the caller frame pointer from the stack + emitter.instruction("leave"); // restore rsp from rbp and pop the caller frame pointer } } } diff --git a/src/codegen_support/abi/mod.rs b/src/codegen_support/abi/mod.rs index 8e9da261f5..24547df599 100644 --- a/src/codegen_support/abi/mod.rs +++ b/src/codegen_support/abi/mod.rs @@ -20,8 +20,8 @@ mod values; #[cfg(test)] pub use bootstrap::emit_copy_frame_pointer; pub use bootstrap::{ - emit_enable_heap_debug_flag, emit_exit, emit_exit_with_result_reg, - emit_store_process_args_to_globals, + emit_enable_heap_debug_flag, emit_enable_web_heap_guard_flag, emit_exit, + emit_exit_with_result_reg, emit_store_process_args_to_globals, }; pub use calls::{ build_outgoing_arg_assignments_for_target, emit_call_label, emit_call_reg, @@ -29,7 +29,7 @@ pub use calls::{ emit_push_float_reg, emit_push_reg, emit_push_reg_pair, emit_push_result_value, emit_release_temporary_stack, emit_reserve_temporary_stack, emit_store_incoming_param, emit_store_to_sp, emit_temporary_stack_address, materialize_outgoing_args, - outgoing_call_stack_pad_bytes, + materialize_outgoing_c_abi_args, outgoing_call_stack_pad_bytes, }; pub use frame::{ emit_frame_prologue, emit_frame_restore, emit_frame_slot_address, emit_load_from_address, diff --git a/src/codegen_support/abi/tests/basics.rs b/src/codegen_support/abi/tests/basics.rs index 30236fd106..227a35973a 100644 --- a/src/codegen_support/abi/tests/basics.rs +++ b/src/codegen_support/abi/tests/basics.rs @@ -10,9 +10,32 @@ use super::*; +/// Verifies AArch64 truthiness helpers use a short inverse branch followed by a +/// wide-range unconditional branch, avoiding `cbz`/`cbnz` fixup overflows in very +/// large generated functions. +#[test] +fn test_emit_branch_helpers_use_long_range_aarch64_sequence() { + let mut emitter = test_emitter(); + emit_branch_if_int_result_zero(&mut emitter, "zero_label"); + emit_branch_if_int_result_nonzero(&mut emitter, "nonzero_label"); + + assert_eq!( + emitter.output(), + concat!( + " cbnz x0, 1f\n", + " b zero_label\n", + "1:\n", + " cbz x0, 1f\n", + " b nonzero_label\n", + "1:\n", + ) + ); +} + /// Tests frame setup and teardown for a small frame (64 bytes). /// Verifies that the prologue allocates 64 bytes, saves FP/LR at sp+#48, -/// sets up x29 as the frame pointer, and that restore/return undo this correctly. +/// sets up x29 as the frame pointer, and that restore/return undo this correctly +/// via the frame-pointer-anchored restore (immune to mid-body sp drift). #[test] fn test_emit_frame_helpers_small_frame() { let mut emitter = test_emitter(); @@ -27,8 +50,9 @@ fn test_emit_frame_helpers_small_frame() { " sub sp, sp, #64\n", " stp x29, x30, [sp, #48]\n", " add x29, sp, #48\n", - " ldp x29, x30, [sp, #48]\n", - " add sp, sp, #64\n", + " mov x9, x29\n", + " add sp, x9, #16\n", + " ldp x29, x30, [x9]\n", " ret\n", ) ); diff --git a/src/codegen_support/abi/tests/linux_x86_64.rs b/src/codegen_support/abi/tests/linux_x86_64.rs index 78a85153e1..04d52b9c47 100644 --- a/src/codegen_support/abi/tests/linux_x86_64.rs +++ b/src/codegen_support/abi/tests/linux_x86_64.rs @@ -97,6 +97,40 @@ fn test_materialize_outgoing_args_for_linux_x86_64_uses_sysv_registers() { assert!(out.contains(" add rsp, 128\n")); } +/// Verifies that consecutive SysV C overflow scalars occupy adjacent 8-byte words +/// while the complete outgoing area remains aligned to 16 bytes. +#[test] +fn test_materialize_outgoing_c_abi_args_for_linux_x86_64_packs_stack_words() { + let mut emitter = test_emitter_x86(); + let assignments = build_outgoing_arg_assignments_for_target( + Target::new(Platform::Linux, Arch::X86_64), + &[ + PhpType::Int, + PhpType::Int, + PhpType::Int, + PhpType::Int, + PhpType::Int, + PhpType::Int, + PhpType::Pointer(None), + PhpType::Pointer(None), + ], + 0, + ); + + let overflow_bytes = materialize_outgoing_c_abi_args(&mut emitter, &assignments); + let out = emitter.output(); + + assert_eq!(overflow_bytes, 16); + assert!(out.contains(" sub rsp, 32\n")); + assert!(out.contains(" mov r10, QWORD PTR [rsp + 48]\n")); + assert!(out.contains(" mov QWORD PTR [rsp], r10\n")); + assert!(out.contains(" mov r10, QWORD PTR [rsp + 32]\n")); + assert!(out.contains(" mov QWORD PTR [rsp + 8], r10\n")); + assert!(out.contains(" mov QWORD PTR [rsp + 144], r10\n")); + assert!(out.contains(" mov QWORD PTR [rsp + 152], r10\n")); + assert!(out.contains(" add rsp, 144\n")); +} + /// Verifies that Linux x86_64 outgoing string args preserve register temps while staging overflow. #[test] fn test_materialize_outgoing_string_args_for_linux_x86_64_preserves_live_rcx() { @@ -125,8 +159,9 @@ fn test_materialize_outgoing_string_args_for_linux_x86_64_preserves_live_rcx() { } /// Verifies that emit_frame_prologue emits push rbp / mov rbp, rsp / sub rsp, N -/// for the x86_64 frame setup; emit_frame_restore emits add rsp, N / pop rbp; -/// and emit_return emits the standard epilogue with ret. The test confirms the +/// for the x86_64 frame setup; emit_frame_restore emits the `leave` idiom (restores +/// rsp from rbp and pops rbp in one step, immune to mid-body rsp drift); and +/// emit_return emits the standard epilogue with ret. The test confirms the /// 16-byte stack alignment requirement is respected. #[test] fn test_emit_frame_helpers_linux_x86_64() { @@ -142,8 +177,7 @@ fn test_emit_frame_helpers_linux_x86_64() { " push rbp\n", " mov rbp, rsp\n", " sub rsp, 32\n", - " add rsp, 32\n", - " pop rbp\n", + " leave\n", " ret\n", ) ); @@ -304,7 +338,7 @@ fn test_emit_store_and_load_result_to_symbol_for_string_linux_x86_64() { /// emit_store_process_args_to_globals stores argc (rdi) and argv (rsi) to /// global symbols; emit_enable_heap_debug_flag sets the heap debug flag; /// emit_copy_frame_pointer copies rbp to a destination register; and -/// emit_exit emits the exit syscall (syscall with eax=60, edi=exit_code). +/// emit_exit emits the process-wide exit syscall (syscall with eax=231, edi=exit_code). #[test] fn test_process_entry_helpers_linux_x86_64() { let mut emitter = test_emitter_x86(); @@ -322,7 +356,7 @@ fn test_process_entry_helpers_linux_x86_64() { assert!(out.contains(" mov QWORD PTR [rip + _heap_debug_enabled], r10\n")); assert!(out.contains(" mov r10, rbp\n")); assert!(out.contains(" mov edi, 7\n")); - assert!(out.contains(" mov eax, 60\n")); + assert!(out.contains(" mov eax, 231\n")); assert!(out.contains(" syscall\n")); } diff --git a/src/codegen_support/abi/values.rs b/src/codegen_support/abi/values.rs index 4e2eb898b0..6a7937bff8 100644 --- a/src/codegen_support/abi/values.rs +++ b/src/codegen_support/abi/values.rs @@ -193,12 +193,16 @@ pub fn emit_load(emitter: &mut Emitter, ty: &PhpType, offset: usize) { /// Branches to `label` when the integer result register is zero (coerced truthiness). /// -/// AArch64: `cbz` (compare and branch if zero). x86_64: `test` + `je` (set cc + conditional jump). +/// AArch64: inverse `cbnz` over an unconditional `b`; x86_64: `test` + `je`. +/// The two-instruction AArch64 sequence gives the destination `b` its +/-128 MiB range instead +/// of `cbz`'s +/-1 MiB range, which large generated PDO/class functions can exceed. /// The integer result represents a coerced PHP truthiness value used in conditional contexts. pub fn emit_branch_if_int_result_zero(emitter: &mut Emitter, label: &str) { match emitter.target.arch { crate::codegen_support::platform::Arch::AArch64 => { - emitter.instruction(&format!("cbz {}, {}", int_result_reg(emitter), label)); // branch when the coerced integer truthiness result is zero + emitter.instruction(&format!("cbnz {}, 1f", int_result_reg(emitter))); // skip the long branch when the coerced truthiness result is nonzero + emitter.instruction(&format!("b {}", label)); // branch with the wider unconditional range when the result is zero + emitter.label("1"); } crate::codegen_support::platform::Arch::X86_64 => { emitter.instruction(&format!( @@ -213,12 +217,16 @@ pub fn emit_branch_if_int_result_zero(emitter: &mut Emitter, label: &str) { /// Branches to `label` when the integer result register is non-zero (coerced truthiness). /// -/// AArch64: `cbnz` (compare and branch if non-zero). x86_64: `test` + `jne` (set cc + conditional jump). +/// AArch64: inverse `cbz` over an unconditional `b`; x86_64: `test` + `jne`. +/// The two-instruction AArch64 sequence gives the destination `b` its +/-128 MiB range instead +/// of `cbnz`'s +/-1 MiB range, which large generated PDO/class functions can exceed. /// The integer result represents a coerced PHP truthiness value used in conditional contexts. pub fn emit_branch_if_int_result_nonzero(emitter: &mut Emitter, label: &str) { match emitter.target.arch { crate::codegen_support::platform::Arch::AArch64 => { - emitter.instruction(&format!("cbnz {}, {}", int_result_reg(emitter), label)); // branch when the coerced integer truthiness result is non-zero + emitter.instruction(&format!("cbz {}, 1f", int_result_reg(emitter))); // skip the long branch when the coerced truthiness result is zero + emitter.instruction(&format!("b {}", label)); // branch with the wider unconditional range when the result is nonzero + emitter.label("1"); } crate::codegen_support::platform::Arch::X86_64 => { emitter.instruction(&format!( diff --git a/src/codegen_support/platform/linux_transform.rs b/src/codegen_support/platform/linux_transform.rs index 0d497b76b4..b94cb95d6a 100644 --- a/src/codegen_support/platform/linux_transform.rs +++ b/src/codegen_support/platform/linux_transform.rs @@ -12,7 +12,7 @@ #[allow(dead_code)] pub(super) fn map_syscall(macos_num: u32) -> u32 { match macos_num { - 1 => 93, + 1 => 94, 3 => 63, 4 => 64, 5 => 56, diff --git a/src/codegen_support/platform/mod.rs b/src/codegen_support/platform/mod.rs index f5cafb0782..87d9d1d096 100644 --- a/src/codegen_support/platform/mod.rs +++ b/src/codegen_support/platform/mod.rs @@ -79,9 +79,9 @@ mod tests { } #[test] - /// macOS syscall numbers 1, 4, 5, 128, 338 map to Linux aarch64 syscall numbers 93, 64, 56, 38, 79. + /// macOS syscall numbers 1, 4, 5, 128, 338 map to Linux aarch64 syscall numbers 94, 64, 56, 38, 79. fn test_map_syscall() { - assert_eq!(map_syscall(1), 93); + assert_eq!(map_syscall(1), 94); assert_eq!(map_syscall(4), 64); assert_eq!(map_syscall(5), 56); assert_eq!(map_syscall(29), 207); @@ -150,7 +150,7 @@ _main: assert!(linux_asm.contains("mov x8, #64\n")); assert!(linux_asm.contains("svc #0\n")); assert!(linux_asm.contains("bl snprintf\n")); - assert!(linux_asm.contains("mov x8, #93\n")); + assert!(linux_asm.contains("mov x8, #94\n")); assert!(!linux_asm.contains("x16")); assert!(!linux_asm.contains("@PAGE")); } diff --git a/src/codegen_support/runtime/arrays/array_get_mixed_key.rs b/src/codegen_support/runtime/arrays/array_get_mixed_key.rs index 4dd0e0f20c..a4a365da1f 100644 --- a/src/codegen_support/runtime/arrays/array_get_mixed_key.rs +++ b/src/codegen_support/runtime/arrays/array_get_mixed_key.rs @@ -12,9 +12,11 @@ //! key normalizes and routes through `__rt_hash_get` if the array has already //! been promoted to hash storage (kind 3). A string key on pure indexed //! storage returns `Mixed(null)` with an undefined-key warning, matching PHP. -//! - Inputs are array pointer, normalized key pair, and a warning flag. The -//! result is always a boxed `Mixed` pointer in x0 (caller owns it). +//! - Inputs are array pointer, normalized key pair, and warning/fetch-mode flags. The result is +//! always a boxed `Mixed` pointer in the target result register (caller owns it). +use crate::codegen_support::abi; +use crate::codegen_support::callable_invoker_args::INVOKER_ARG_REF_CELL_TAG; use crate::codegen_support::emit::Emitter; use crate::codegen_support::platform::Arch; @@ -33,7 +35,7 @@ pub fn emit_array_get_mixed_key(emitter: &mut Emitter) { // [sp, #0] = array_ptr // [sp, #8] = key_lo // [sp, #16] = key_hi - // [sp, #24] = warn_on_missing + // [sp, #24] = flags (bit 0: warn_on_missing, bit 1: fetch_for_write) // [sp, #32] = saved x29 // [sp, #40] = saved x30 emitter.instruction("sub sp, sp, #64"); // reserve frame: 4 inputs + saved fp/lr (16-byte aligned) @@ -42,7 +44,7 @@ pub fn emit_array_get_mixed_key(emitter: &mut Emitter) { emitter.instruction("str x0, [sp, #0]"); // save the incoming array pointer emitter.instruction("str x1, [sp, #8]"); // save the key low word emitter.instruction("str x2, [sp, #16]"); // save the key high word (sentinel) - emitter.instruction("str x3, [sp, #24]"); // save whether missing keys should emit PHP warnings + emitter.instruction("str x3, [sp, #24]"); // save warning and fetch-for-write mode flags emitter.instruction("cbz x0, __rt_array_get_mixed_key_null_receiver"); // null array → optional warning + Mixed(null) crate::codegen_support::abi::emit_load_int_immediate( @@ -78,7 +80,7 @@ pub fn emit_array_get_mixed_key(emitter: &mut Emitter) { emitter.instruction("ubfx x13, x13, #8, #7"); // extract the runtime element value_type tag emitter.instruction("add x10, x0, #24"); // skip the 24-byte array header to reach the contiguous payload emitter.instruction("cmp x13, #7"); // are indexed slots already boxed Mixed pointers? - emitter.instruction("b.eq __rt_array_get_mixed_key_indexed_boxed"); // boxed slots must be retained before returning + emitter.instruction("b.eq __rt_array_get_mixed_key_indexed_boxed"); // boxed slots are copied into a fresh zval cell before returning emitter.instruction("cmp x13, #1"); // do indexed slots contain string pointer/length pairs? emitter.instruction("b.eq __rt_array_get_mixed_key_indexed_string"); // string slots need a 16-byte load before boxing emitter.instruction("cmp x13, #8"); // do indexed slots represent null payloads? @@ -94,7 +96,58 @@ pub fn emit_array_get_mixed_key(emitter: &mut Emitter) { emitter.label("__rt_array_get_mixed_key_indexed_boxed"); emitter.instruction("ldr x0, [x10, x12, lsl #3]"); // load the boxed Mixed pointer from the indexed slot emitter.instruction("cbz x0, __rt_array_get_mixed_key_null"); // empty slot → null Mixed - emitter.instruction("bl __rt_incref"); // retain the stored Mixed cell so the caller owns the returned result + emitter.instruction("ldr x9, [sp, #24]"); // reload the warning/fetch mode flags + emitter.instruction("tbnz x9, #1, __rt_array_get_mixed_key_detach_indexed"); // split the selected zval after outer-array COW + emitter.label("__rt_array_get_mixed_key_clone_boxed"); + emitter.instruction("ldr x9, [x0]"); // inspect the stored Mixed tag before cloning the cell + emitter.instruction(&format!("cmp x9, #{}", INVOKER_ARG_REF_CELL_TAG)); // is this slot a by-reference variadic marker? + emitter.instruction("b.eq __rt_array_get_mixed_key_clone_ref_cell"); // dereference marker slots instead of returning the marker itself + emitter.instruction("ldr x9, [sp, #24]"); // reload the warning/fetch mode flags + emitter.instruction("tbnz x9, #1, __rt_array_get_mixed_key_retain_boxed"); // write fetches must preserve the stored mutable cell identity + emitter.instruction("bl __rt_mixed_clone"); // detach values while preserving shared PHP resource identity + emitter.instruction("b __rt_array_get_mixed_key_clone_done"); // share the helper epilogue with ref-cell cloning + + emitter.label("__rt_array_get_mixed_key_detach_indexed"); + emitter.instruction("str x0, [sp, #48]"); // preserve the old cell whose array-owner reference will be replaced + emitter.instruction("bl __rt_mixed_clone"); // detach the selected zval while resources retain shared identity + emitter.instruction("str x0, [sp, #56]"); // preserve the replacement across old-cell cleanup + emitter.instruction("ldr x9, [sp, #0]"); // reload the unique indexed-array pointer + emitter.instruction("ldr x10, [sp, #8]"); // reload the selected integer index + emitter.instruction("add x9, x9, #24"); // address the boxed Mixed payload base + emitter.instruction("str x0, [x9, x10, lsl #3]"); // publish the detached zval in the unique outer array + emitter.instruction("ldr x0, [sp, #48]"); // drop the replaced array-owner reference to the shared cell + emitter.instruction("bl __rt_decref_mixed"); // release the old cell without affecting aliases that still own it + emitter.instruction("ldr x0, [sp, #56]"); // reload the detached cell now owned by the array + emitter.instruction("bl __rt_incref"); // add the caller-owned reference consumed after the nested write + emitter.instruction("b __rt_array_get_mixed_key_clone_done"); // return through the shared epilogue + + emitter.label("__rt_array_get_mixed_key_clone_ref_cell"); + emitter.instruction("ldr x10, [x0, #8]"); // load the caller storage address carried by the marker + emitter.instruction("ldr x9, [x0, #16]"); // load the runtime tag of the referenced value + emitter.instruction("ldr x1, [x10]"); // load the referenced low payload word + emitter.instruction("mov x2, #0"); // scalar ref-cells have no high payload word + emitter.instruction("cmp x9, #7"); // does the caller storage contain another boxed Mixed cell? + emitter.instruction("b.eq __rt_array_get_mixed_key_clone_ref_mixed"); // unbox nested Mixed storage before cloning it + emitter.instruction("cmp x9, #1"); // does the caller storage contain a string pair? + emitter.instruction("b.eq __rt_array_get_mixed_key_clone_ref_string"); // load the referenced string length before boxing + emitter.label("__rt_array_get_mixed_key_clone_ref_box"); + emitter.instruction("mov x0, x9"); // x0 = referenced runtime value tag + emitter.instruction("bl __rt_mixed_from_value"); // box the current referenced scalar or pointer value + emitter.instruction("b __rt_array_get_mixed_key_clone_done"); // return through the shared helper epilogue + emitter.label("__rt_array_get_mixed_key_clone_ref_string"); + emitter.instruction("ldr x2, [x10, #8]"); // load the referenced string length + emitter.instruction("b __rt_array_get_mixed_key_clone_ref_box"); // box the referenced string pointer/length pair + emitter.label("__rt_array_get_mixed_key_clone_ref_mixed"); + emitter.instruction("mov x0, x1"); // pass the referenced boxed Mixed handle to the unbox helper + emitter.instruction("ldr x9, [sp, #24]"); // reload the warning/fetch mode flags + emitter.instruction("tbnz x9, #1, __rt_array_get_mixed_key_retain_boxed"); // a referenced Mixed write must target the referenced cell itself + emitter.instruction("bl __rt_mixed_clone"); // clone the referenced value with resource-aware ownership + emitter.instruction("b __rt_array_get_mixed_key_clone_done"); // skip the retain-only path used by write fetches + + emitter.label("__rt_array_get_mixed_key_retain_boxed"); + emitter.instruction("bl __rt_incref"); // give the nested writer one owned reference to the stored cell + + emitter.label("__rt_array_get_mixed_key_clone_done"); emitter.instruction("ldp x29, x30, [sp, #32]"); // restore frame pointer and return address emitter.instruction("add sp, sp, #64"); // release the local frame emitter.instruction("ret"); // return Mixed* in x0 @@ -143,11 +196,8 @@ pub fn emit_array_get_mixed_key(emitter: &mut Emitter) { emitter.instruction("cbz x0, __rt_array_get_mixed_key_hash_missing"); // miss → optional warning + null emitter.instruction("cmp x3, #7"); // is the hash entry already a boxed Mixed? emitter.instruction("b.ne __rt_array_get_mixed_key_hash_box"); // no → box (lo, hi, tag) into a fresh Mixed cell - emitter.instruction("mov x0, x1"); // yes → move the stored Mixed cell into the return register - emitter.instruction("bl __rt_incref"); // retain the stored Mixed cell so the caller owns the returned result - emitter.instruction("ldp x29, x30, [sp, #32]"); // restore frame pointer and return address - emitter.instruction("add sp, sp, #64"); // release the local frame - emitter.instruction("ret"); // return Mixed* in x0 + emitter.instruction("mov x0, x1"); // yes → load the stored Mixed cell into the unbox input register + emitter.instruction("b __rt_array_get_mixed_key_clone_boxed"); // clone ordinary cells or dereference variadic ref-cell markers emitter.label("__rt_array_get_mixed_key_hash_box"); emitter.instruction("mov x0, x3"); // x0 = value_tag (mixed_from_value first arg) emitter.instruction("mov x1, x1"); // x1 = value_lo (already in place) @@ -200,14 +250,14 @@ fn emit_array_get_mixed_key_linux_x86_64(emitter: &mut Emitter) { // [rbp - 8] = array_ptr // [rbp - 16] = key_lo // [rbp - 24] = key_hi - // [rbp - 32] = warn_on_missing + // [rbp - 32] = flags (bit 0: warn_on_missing, bit 1: fetch_for_write) emitter.instruction("push rbp"); // save caller frame pointer emitter.instruction("mov rbp, rsp"); // establish helper frame pointer - emitter.instruction("sub rsp, 32"); // reserve 32 bytes for locals (16-byte aligned) + emitter.instruction("sub rsp, 48"); // reserve inputs plus write-detach spill slots (16-byte aligned) emitter.instruction("mov QWORD PTR [rbp - 8], rdi"); // save the incoming array pointer emitter.instruction("mov QWORD PTR [rbp - 16], rsi"); // save the key low word emitter.instruction("mov QWORD PTR [rbp - 24], rdx"); // save the key high word (sentinel) - emitter.instruction("mov QWORD PTR [rbp - 32], rcx"); // save whether missing keys should emit PHP warnings + emitter.instruction("mov QWORD PTR [rbp - 32], rcx"); // save warning and fetch-for-write mode flags emitter.instruction("test rdi, rdi"); // null array check emitter.instruction("je __rt_array_get_mixed_key_null_receiver"); // null array → optional warning + Mixed(null) @@ -234,42 +284,96 @@ fn emit_array_get_mixed_key_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("jne __rt_array_get_mixed_key_string_on_indexed"); // string key on indexed storage → warn + null // -- integer key on indexed storage: inline bounds-checked read -- - emitter.instruction("mov r12, QWORD PTR [rbp - 16]"); // r12 = key_lo (int index) + emitter.instruction("mov r8, QWORD PTR [rbp - 16]"); // r8 = key_lo (int index); caller-saved scratch only emitter.instruction("mov r9, QWORD PTR [rdi]"); // r9 = array length (header offset 0) - emitter.instruction("test r12, r12"); // negative index → null + emitter.instruction("test r8, r8"); // negative index → null emitter.instruction("js __rt_array_get_mixed_key_int_missing"); // warn and return null for a negative indexed-array key - emitter.instruction("cmp r12, r9"); // index >= length → null + emitter.instruction("cmp r8, r9"); // index >= length → null emitter.instruction("jge __rt_array_get_mixed_key_int_missing"); // warn and return null for an out-of-bounds indexed-array key - emitter.instruction("mov r13, QWORD PTR [rdi - 8]"); // reload kind metadata for element type tag - emitter.instruction("shr r13, 8"); // shift the element type tag into the low 7 bits - emitter.instruction("and r13, 0x7f"); // mask the element type tag + emitter.instruction("mov r11, QWORD PTR [rdi - 8]"); // reload kind metadata for element type tag + emitter.instruction("shr r11, 8"); // shift the element type tag into the low 7 bits + emitter.instruction("and r11, 0x7f"); // mask the element type tag emitter.instruction("lea r10, [rdi + 24]"); // skip the 24-byte array header to reach the contiguous payload - emitter.instruction("cmp r13, 7"); // are indexed slots already boxed Mixed pointers? - emitter.instruction("je __rt_array_get_mixed_key_indexed_boxed"); // boxed slots must be retained before returning - emitter.instruction("cmp r13, 1"); // do indexed slots contain string pointer/length pairs? + emitter.instruction("cmp r11, 7"); // are indexed slots already boxed Mixed pointers? + emitter.instruction("je __rt_array_get_mixed_key_indexed_boxed"); // boxed slots are copied into a fresh zval cell before returning + emitter.instruction("cmp r11, 1"); // do indexed slots contain string pointer/length pairs? emitter.instruction("je __rt_array_get_mixed_key_indexed_string"); // string slots need a 16-byte load before boxing - emitter.instruction("cmp r13, 8"); // do indexed slots represent null payloads? + emitter.instruction("cmp r11, 8"); // do indexed slots represent null payloads? emitter.instruction("je __rt_array_get_mixed_key_indexed_null"); // null slots have no payload to read - emitter.instruction("mov rdi, QWORD PTR [r10 + r12 * 8]"); // load scalar or pointer payload from the typed indexed slot + emitter.instruction("mov rdi, QWORD PTR [r10 + r8 * 8]"); // load scalar or pointer payload from the typed indexed slot emitter.instruction("xor rsi, rsi"); // typed indexed slots use one payload word except strings - emitter.instruction("mov rax, r13"); // rax = runtime value_type tag for the boxed result + emitter.instruction("mov rax, r11"); // rax = runtime value_type tag for the boxed result emitter.instruction("call __rt_mixed_from_value"); // box the typed indexed-array element into a Mixed cell emitter.instruction("mov rsp, rbp"); // release the helper frame emitter.instruction("pop rbp"); // restore caller frame pointer emitter.instruction("ret"); // return Mixed* in rax emitter.label("__rt_array_get_mixed_key_indexed_boxed"); - emitter.instruction("mov rax, QWORD PTR [r10 + r12 * 8]"); // load the boxed Mixed pointer from the indexed slot + emitter.instruction("mov rax, QWORD PTR [r10 + r8 * 8]"); // load the boxed Mixed pointer from the indexed slot emitter.instruction("test rax, rax"); // empty slot → null Mixed emitter.instruction("je __rt_array_get_mixed_key_null"); // return null for an empty boxed slot - emitter.instruction("call __rt_incref"); // retain the stored Mixed cell so the caller owns the returned result + emitter.instruction("test QWORD PTR [rbp - 32], 2"); // does this read feed an imminent nested write? + emitter.instruction("jnz __rt_array_get_mixed_key_detach_indexed"); // split the selected zval after outer-array COW + emitter.label("__rt_array_get_mixed_key_clone_boxed"); + emitter.instruction("mov r11, QWORD PTR [rax]"); // inspect the stored Mixed tag before cloning the cell + emitter.instruction(&format!("cmp r11, {}", INVOKER_ARG_REF_CELL_TAG)); // is this slot a by-reference variadic marker? + emitter.instruction("je __rt_array_get_mixed_key_clone_ref_cell"); // dereference marker slots instead of returning the marker itself + emitter.instruction("test QWORD PTR [rbp - 32], 2"); // does this read feed an imminent nested write? + emitter.instruction("jnz __rt_array_get_mixed_key_retain_boxed"); // preserve the stored mutable cell identity for the writer + emitter.instruction("call __rt_mixed_clone"); // detach values while preserving shared PHP resource identity + emitter.instruction("jmp __rt_array_get_mixed_key_clone_done"); // share the helper epilogue with ref-cell cloning + + emitter.label("__rt_array_get_mixed_key_detach_indexed"); + emitter.instruction("mov QWORD PTR [rbp - 40], rax"); // preserve the old cell whose array-owner reference will be replaced + emitter.instruction("call __rt_mixed_clone"); // detach the selected zval while resources retain shared identity + emitter.instruction("mov QWORD PTR [rbp - 48], rax"); // preserve the replacement across old-cell cleanup + emitter.instruction("mov r10, QWORD PTR [rbp - 8]"); // reload the unique indexed-array pointer + emitter.instruction("mov r8, QWORD PTR [rbp - 16]"); // reload the selected integer index + emitter.instruction("mov QWORD PTR [r10 + 24 + r8 * 8], rax"); // publish the detached zval in the unique outer array + emitter.instruction("mov rax, QWORD PTR [rbp - 40]"); // drop the replaced array-owner reference to the shared cell + emitter.instruction("call __rt_decref_mixed"); // release the old cell without affecting aliases that still own it + emitter.instruction("mov rax, QWORD PTR [rbp - 48]"); // reload the detached cell now owned by the array + abi::emit_push_reg(emitter, "rax"); + emitter.instruction("call __rt_incref"); // add the caller-owned reference consumed after the nested write + abi::emit_pop_reg(emitter, "rax"); + emitter.instruction("jmp __rt_array_get_mixed_key_clone_done"); // return through the shared epilogue + + emitter.label("__rt_array_get_mixed_key_clone_ref_cell"); + emitter.instruction("mov r10, QWORD PTR [rax + 8]"); // load the caller storage address carried by the marker + emitter.instruction("mov r11, QWORD PTR [rax + 16]"); // load the runtime tag of the referenced value + emitter.instruction("mov rdi, QWORD PTR [r10]"); // load the referenced low payload word + emitter.instruction("xor rsi, rsi"); // scalar ref-cells have no high payload word + emitter.instruction("cmp r11, 7"); // does the caller storage contain another boxed Mixed cell? + emitter.instruction("je __rt_array_get_mixed_key_clone_ref_mixed"); // unbox nested Mixed storage before cloning it + emitter.instruction("cmp r11, 1"); // does the caller storage contain a string pair? + emitter.instruction("je __rt_array_get_mixed_key_clone_ref_string"); // load the referenced string length before boxing + emitter.label("__rt_array_get_mixed_key_clone_ref_box"); + emitter.instruction("mov rax, r11"); // rax = referenced runtime value tag + emitter.instruction("call __rt_mixed_from_value"); // box the current referenced scalar or pointer value + emitter.instruction("jmp __rt_array_get_mixed_key_clone_done"); // return through the shared helper epilogue + emitter.label("__rt_array_get_mixed_key_clone_ref_string"); + emitter.instruction("mov rsi, QWORD PTR [r10 + 8]"); // load the referenced string length + emitter.instruction("jmp __rt_array_get_mixed_key_clone_ref_box"); // box the referenced string pointer/length pair + emitter.label("__rt_array_get_mixed_key_clone_ref_mixed"); + emitter.instruction("mov rax, rdi"); // pass the referenced boxed Mixed handle to the unbox helper + emitter.instruction("test QWORD PTR [rbp - 32], 2"); // does this read feed an imminent nested write? + emitter.instruction("jnz __rt_array_get_mixed_key_retain_boxed"); // target the referenced cell itself instead of cloning it + emitter.instruction("call __rt_mixed_clone"); // clone the referenced value with resource-aware ownership + emitter.instruction("jmp __rt_array_get_mixed_key_clone_done"); // skip the retain-only path used by write fetches + + emitter.label("__rt_array_get_mixed_key_retain_boxed"); + abi::emit_push_reg(emitter, "rax"); + emitter.instruction("call __rt_incref"); // give the nested writer one owned reference to the stored cell + abi::emit_pop_reg(emitter, "rax"); + + emitter.label("__rt_array_get_mixed_key_clone_done"); emitter.instruction("mov rsp, rbp"); // release the helper frame emitter.instruction("pop rbp"); // restore caller frame pointer emitter.instruction("ret"); // return Mixed* in rax emitter.label("__rt_array_get_mixed_key_indexed_string"); - emitter.instruction("shl r12, 4"); // convert the element index to a 16-byte string slot offset - emitter.instruction("add r10, r12"); // r10 = address of the selected string slot + emitter.instruction("shl r8, 4"); // convert the element index to a 16-byte string slot offset + emitter.instruction("add r10, r8"); // r10 = address of the selected string slot emitter.instruction("mov rdi, QWORD PTR [r10]"); // load string pointer from the selected slot emitter.instruction("mov rsi, QWORD PTR [r10 + 8]"); // load string length from the selected slot emitter.instruction("mov rax, 1"); // rax = string runtime value_type tag @@ -309,20 +413,17 @@ fn emit_array_get_mixed_key_linux_x86_64(emitter: &mut Emitter) { emitter.label("__rt_array_get_mixed_key_hash"); emitter.instruction("mov rsi, QWORD PTR [rbp - 16]"); // rsi = key_lo emitter.instruction("mov rdx, QWORD PTR [rbp - 24]"); // rdx = key_hi - emitter.instruction("call __rt_hash_get"); // rax=found, rsi=value_lo, rdx=value_hi, rcx=value_tag + emitter.instruction("call __rt_hash_get"); // rax=found, rdi=value_lo, rsi=value_hi, rcx=value_tag emitter.instruction("test rax, rax"); // miss → null emitter.instruction("je __rt_array_get_mixed_key_hash_missing"); // miss → optional warning + null emitter.instruction("cmp rcx, 7"); // is the hash entry already a boxed Mixed? emitter.instruction("jne __rt_array_get_mixed_key_hash_box"); // no → box (lo, hi, tag) into a fresh Mixed cell - emitter.instruction("mov rax, rsi"); // yes → move the stored Mixed cell into the return register - emitter.instruction("call __rt_incref"); // retain the stored Mixed cell so the caller owns the returned result - emitter.instruction("mov rsp, rbp"); // release the helper frame - emitter.instruction("pop rbp"); // restore caller frame pointer - emitter.instruction("ret"); // return Mixed* in rax + emitter.instruction("mov rax, rdi"); // yes → load the stored Mixed cell into the unbox input register + emitter.instruction("jmp __rt_array_get_mixed_key_clone_boxed"); // clone ordinary cells or dereference variadic ref-cell markers emitter.label("__rt_array_get_mixed_key_hash_box"); + // rdi=value_lo, rsi=value_hi already sit in __rt_mixed_from_value's expected + // input registers straight out of __rt_hash_get — only the tag needs moving. emitter.instruction("mov rax, rcx"); // rax = value_tag (mixed_from_value first arg) - emitter.instruction("mov rdi, rsi"); // rdi = value_lo from hash_get - emitter.instruction("mov rsi, rdx"); // rsi = value_hi from hash_get emitter.instruction("call __rt_mixed_from_value"); // box the hash entry into a Mixed cell emitter.instruction("mov rsp, rbp"); // release the helper frame emitter.instruction("pop rbp"); // restore caller frame pointer diff --git a/src/codegen_support/runtime/arrays/array_key_exists.rs b/src/codegen_support/runtime/arrays/array_key_exists.rs index 76c2024a25..10c63232a4 100644 --- a/src/codegen_support/runtime/arrays/array_key_exists.rs +++ b/src/codegen_support/runtime/arrays/array_key_exists.rs @@ -1,5 +1,5 @@ //! Purpose: -//! Emits the `__rt_array_key_exists`, `__rt_array_key_exists_no` runtime helper assembly for array key exists. +//! Emits the `__rt_array_key_exists` runtime helper assembly for array key exists. //! Keeps PHP array/hash storage, heap ownership, and target-specific ABI variants in one focused emitter. //! //! Called from: @@ -7,17 +7,28 @@ //! //! Key details: //! - Array helpers operate on runtime array headers and element cells; mutations must respect capacity and COW contracts. +//! - This helper used to inline a bounds check against the array header's first word. +//! That is only correct on kind-2 (packed) storage: an `Array(_)`-typed local can be +//! backed by *hash* storage at runtime (a mixed-key write promotes the storage, while +//! the checker only promotes the static type to `AssocArray` at a provably string-keyed +//! write), and on a hash header the first word is the live-entry COUNT, not a length — +//! so `array_key_exists(0, $promoted)` answered `true` for a key that does not exist. +//! It is now a thin adapter over the storage-kind-dispatching +//! `__rt_array_key_exists_mixed_key`, whose packed arm performs the very same +//! bounds check and whose hash arm delegates to `__rt_hash_get`'s found flag. use crate::codegen_support::emit::Emitter; use crate::codegen_support::platform::Arch; -/// Checks whether an integer key exists in an indexed array by comparing against the array length in the header. +/// Checks whether an integer key exists in an indexed array, whatever its runtime storage kind. /// -/// ABI: x0 = array pointer, x1 = integer key -/// Returns: x0 = 1 if key exists and is in bounds [0, length); x0 = 0 otherwise +/// ABI: x0 = array pointer, x1 = integer key (AArch64); rdi / rsi (x86_64). +/// Returns: x0 (AArch64) / rax (x86_64) = 1 if the key exists, 0 otherwise. /// -/// Negative keys are rejected before the upper-bound comparison and fall through to the "no" path. -/// The caller receives the result directly in x0; no other registers are modified. +/// Tail-branches into `__rt_array_key_exists_mixed_key` after tagging the key as an integer +/// one (`key_hi = -1`, the int-key sentinel that helper and `__rt_hash_get` both expect). +/// The branch is a tail call, not a `bl`/`call`, so no frame is needed and the link register +/// still points at this helper's caller. pub fn emit_array_key_exists(emitter: &mut Emitter) { if emitter.target.arch == Arch::X86_64 { emit_array_key_exists_linux_x86_64(emitter); @@ -28,40 +39,17 @@ pub fn emit_array_key_exists(emitter: &mut Emitter) { emitter.comment("--- runtime: array_key_exists ---"); emitter.label_global("__rt_array_key_exists"); - // -- check if key is in bounds [0, length) -- - emitter.instruction("ldr x9, [x0]"); // x9 = current array length from header - emitter.instruction("cmp x1, #0"); // check if key is negative - emitter.instruction("b.lt __rt_array_key_exists_no"); // negative keys don't exist - emitter.instruction("cmp x1, x9"); // compare key with array length - emitter.instruction("b.ge __rt_array_key_exists_no"); // if key >= length, does not exist - - // -- key exists -- - emitter.instruction("mov x0, #1"); // return true - emitter.instruction("ret"); // return to caller - - // -- key does not exist -- - emitter.label("__rt_array_key_exists_no"); - emitter.instruction("mov x0, #0"); // return false - emitter.instruction("ret"); // return to caller + emitter.instruction("mov x2, #-1"); // key_hi sentinel: tag the incoming key as an integer key + emitter.instruction("b __rt_array_key_exists_mixed_key"); // tail-call the storage-kind-dispatching presence probe } /// x86_64 Linux variant of `emit_array_key_exists`. /// Uses the System V AMD64 ABI: rdi = array pointer, rsi = integer key, rax = return value. -/// Negative keys are rejected before the upper-bound comparison; out-of-bounds keys return 0 in rax. fn emit_array_key_exists_linux_x86_64(emitter: &mut Emitter) { emitter.blank(); emitter.comment("--- runtime: array_key_exists ---"); emitter.label_global("__rt_array_key_exists"); - emitter.instruction("mov r10, QWORD PTR [rdi]"); // load the current array length from the indexed-array header - emitter.instruction("cmp rsi, 0"); // negative integer keys never exist in indexed arrays - emitter.instruction("jl __rt_array_key_exists_no"); // reject negative keys before the upper-bound comparison - emitter.instruction("cmp rsi, r10"); // compare the candidate key against the current array length - emitter.instruction("jge __rt_array_key_exists_no"); // keys at or beyond length do not exist in the indexed array - emitter.instruction("mov rax, 1"); // return true once the key is proven to be in bounds - emitter.instruction("ret"); // return the success flag to the caller - - emitter.label("__rt_array_key_exists_no"); - emitter.instruction("xor eax, eax"); // return false when the integer key is out of bounds - emitter.instruction("ret"); // return the failure flag to the caller + emitter.instruction("mov rdx, -1"); // key_hi sentinel: tag the incoming key as an integer key + emitter.instruction("jmp __rt_array_key_exists_mixed_key"); // tail-call the storage-kind-dispatching presence probe } diff --git a/src/codegen_support/runtime/arrays/array_key_exists_mixed_key.rs b/src/codegen_support/runtime/arrays/array_key_exists_mixed_key.rs new file mode 100644 index 0000000000..9f0c1a7faa --- /dev/null +++ b/src/codegen_support/runtime/arrays/array_key_exists_mixed_key.rs @@ -0,0 +1,153 @@ +//! Purpose: +//! Emits the `__rt_array_key_exists_mixed_key` runtime helper: presence-only +//! `array_key_exists()` for a statically `Array(_)` indexed local whose key is +//! a boxed `Mixed` cell or a string — the presence-only sibling of +//! `__rt_array_get_mixed_key`. +//! +//! Called from: +//! - `crate::codegen_support::runtime::emitters::emit_runtime()` via `crate::codegen_support::runtime::arrays`. +//! +//! Key details: +//! - The key tag is only known at runtime. The helper tag-dispatches on the +//! array's storage kind exactly like `__rt_array_get_mixed_key`: kind 2 +//! (packed/indexed) storage never holds a string key, so a string key there +//! is always absent; kind 3 (hash) storage delegates straight to +//! `__rt_hash_get`'s found flag. +//! - Unlike `__rt_array_get_mixed_key`, this never materializes, boxes, or +//! retains a value and never warns — `array_key_exists()` is presence-only +//! and silent, and critically must answer `true` for a key whose stored +//! value is null (the exact case `isset()` must answer `false` for), so it +//! cannot be built by reusing the read helper plus an is-null check. +//! - Inputs are array pointer and normalized key pair. The result is a plain +//! 0/1 found flag in x0 (AArch64) / rax (x86_64). + +use crate::codegen_support::emit::Emitter; +use crate::codegen_support::platform::Arch; + +/// Emits the mixed-key indexed/hash array presence probe for the current target. +pub fn emit_array_key_exists_mixed_key(emitter: &mut Emitter) { + if emitter.target.arch == Arch::X86_64 { + emit_array_key_exists_mixed_key_linux_x86_64(emitter); + return; + } + + emitter.blank(); + emitter.comment("--- runtime: array_key_exists_mixed_key ---"); + emitter.label_global("__rt_array_key_exists_mixed_key"); + + // Stack: + // [sp, #0] = array_ptr + // [sp, #8] = key_lo + // [sp, #16] = key_hi + // [sp, #32] = saved x29 + // [sp, #40] = saved x30 + emitter.instruction("sub sp, sp, #48"); // reserve frame: 3 inputs + saved fp/lr (16-byte aligned) + emitter.instruction("stp x29, x30, [sp, #32]"); // save frame pointer and return address + emitter.instruction("add x29, sp, #32"); // establish a helper frame pointer + emitter.instruction("str x0, [sp, #0]"); // save the incoming array pointer + emitter.instruction("str x1, [sp, #8]"); // save the key low word + emitter.instruction("str x2, [sp, #16]"); // save the key high word (sentinel) + + emitter.instruction("cbz x0, __rt_array_key_exists_mixed_key_not_found"); // null array → not found + + // -- dispatch on array storage kind -- + emitter.instruction("ldr x9, [x0, #-8]"); // load packed kind metadata from the array header + emitter.instruction("and x9, x9, #0xff"); // isolate the low byte (kind tag) + emitter.instruction("cmp x9, #3"); // kind 3 = hash storage? + emitter.instruction("b.eq __rt_array_key_exists_mixed_key_hash"); // route hash-storage arrays through hash_get's found flag + emitter.instruction("cmp x9, #2"); // kind 2 = indexed storage? + emitter.instruction("b.ne __rt_array_key_exists_mixed_key_not_found"); // unknown kind → not found + + // -- indexed storage: dispatch on key tag -- + emitter.label("__rt_array_key_exists_mixed_key_indexed"); + emitter.instruction("ldr x11, [sp, #16]"); // reload key_hi + emitter.instruction("cmn x11, #1"); // compare with -1 (int-key sentinel) + emitter.instruction("b.ne __rt_array_key_exists_mixed_key_not_found"); // a string key never exists on packed/indexed storage + + // -- integer key on indexed storage: bounds-check only -- + emitter.instruction("ldr x12, [sp, #8]"); // x12 = key_lo (int index) + emitter.instruction("ldr x9, [x0]"); // x9 = array length (header offset 0) + emitter.instruction("cmp x12, #0"); // negative index → not found + emitter.instruction("b.lt __rt_array_key_exists_mixed_key_not_found"); // negative indexed-array keys never exist + emitter.instruction("cmp x12, x9"); // index >= length → not found + emitter.instruction("b.ge __rt_array_key_exists_mixed_key_not_found"); // out-of-bounds indexed-array keys never exist + emitter.instruction("mov x0, #1"); // in-bounds integer key → found + emitter.instruction("b __rt_array_key_exists_mixed_key_done"); // skip the hash and not-found arms + + // -- hash storage: delegate to __rt_hash_get's found flag --- + emitter.label("__rt_array_key_exists_mixed_key_hash"); + emitter.instruction("ldr x1, [sp, #8]"); // x1 = key_lo + emitter.instruction("ldr x2, [sp, #16]"); // x2 = key_hi + emitter.instruction("bl __rt_hash_get"); // x0 = found (a null-valued entry still reports found) + emitter.instruction("b __rt_array_key_exists_mixed_key_done"); // hash_get's found flag is already the result + + // -- not found -- + emitter.label("__rt_array_key_exists_mixed_key_not_found"); + emitter.instruction("mov x0, #0"); // x0 = not found + + emitter.label("__rt_array_key_exists_mixed_key_done"); + emitter.instruction("ldp x29, x30, [sp, #32]"); // restore frame pointer and return address + emitter.instruction("add sp, sp, #48"); // release the local frame + emitter.instruction("ret"); // return found flag in x0 +} + +/// Emits the x86_64 variant of `__rt_array_key_exists_mixed_key`. +fn emit_array_key_exists_mixed_key_linux_x86_64(emitter: &mut Emitter) { + emitter.blank(); + emitter.comment("--- runtime: array_key_exists_mixed_key ---"); + emitter.label_global("__rt_array_key_exists_mixed_key"); + + // Stack layout (16-byte aligned): + // [rbp - 8] = array_ptr + // [rbp - 16] = key_lo + // [rbp - 24] = key_hi + emitter.instruction("push rbp"); // save caller frame pointer + emitter.instruction("mov rbp, rsp"); // establish helper frame pointer + emitter.instruction("sub rsp, 32"); // reserve 32 bytes for locals (16-byte aligned) + emitter.instruction("mov QWORD PTR [rbp - 8], rdi"); // save the incoming array pointer + emitter.instruction("mov QWORD PTR [rbp - 16], rsi"); // save the key low word + emitter.instruction("mov QWORD PTR [rbp - 24], rdx"); // save the key high word (sentinel) + + emitter.instruction("test rdi, rdi"); // null array check + emitter.instruction("je __rt_array_key_exists_mixed_key_not_found"); // null array → not found + + // -- dispatch on array storage kind -- + emitter.instruction("mov r9, QWORD PTR [rdi - 8]"); // load packed kind metadata from the array header + emitter.instruction("and r9, 0xff"); // isolate the low byte (kind tag) + emitter.instruction("cmp r9, 3"); // kind 3 = hash storage? + emitter.instruction("je __rt_array_key_exists_mixed_key_hash"); // route hash-storage arrays through hash_get's found flag + emitter.instruction("cmp r9, 2"); // kind 2 = indexed storage? + emitter.instruction("jne __rt_array_key_exists_mixed_key_not_found"); // unknown kind → not found + + // -- indexed storage: dispatch on key tag -- + emitter.label("__rt_array_key_exists_mixed_key_indexed"); + emitter.instruction("mov r11, QWORD PTR [rbp - 24]"); // reload key_hi + emitter.instruction("cmp r11, -1"); // compare with -1 (int-key sentinel) + emitter.instruction("jne __rt_array_key_exists_mixed_key_not_found"); // a string key never exists on packed/indexed storage + + // -- integer key on indexed storage: bounds-check only -- + emitter.instruction("mov r10, QWORD PTR [rbp - 16]"); // r10 = key_lo (int index); caller-saved scratch only + emitter.instruction("mov r9, QWORD PTR [rdi]"); // r9 = array length (header offset 0) + emitter.instruction("test r10, r10"); // negative index → not found + emitter.instruction("js __rt_array_key_exists_mixed_key_not_found"); // negative indexed-array keys never exist + emitter.instruction("cmp r10, r9"); // index >= length → not found + emitter.instruction("jge __rt_array_key_exists_mixed_key_not_found"); // out-of-bounds indexed-array keys never exist + emitter.instruction("mov rax, 1"); // in-bounds integer key → found + emitter.instruction("jmp __rt_array_key_exists_mixed_key_done"); // skip the hash and not-found arms + + // -- hash storage: delegate to __rt_hash_get's found flag --- + emitter.label("__rt_array_key_exists_mixed_key_hash"); + emitter.instruction("mov rsi, QWORD PTR [rbp - 16]"); // rsi = key_lo + emitter.instruction("mov rdx, QWORD PTR [rbp - 24]"); // rdx = key_hi + emitter.instruction("call __rt_hash_get"); // rax = found (a null-valued entry still reports found) + emitter.instruction("jmp __rt_array_key_exists_mixed_key_done"); // hash_get's found flag is already the result + + // -- not found -- + emitter.label("__rt_array_key_exists_mixed_key_not_found"); + emitter.instruction("xor eax, eax"); // rax = not found + + emitter.label("__rt_array_key_exists_mixed_key_done"); + emitter.instruction("mov rsp, rbp"); // release the helper frame + emitter.instruction("pop rbp"); // restore caller frame pointer + emitter.instruction("ret"); // return found flag in rax +} diff --git a/src/codegen_support/runtime/arrays/array_set_mixed_key.rs b/src/codegen_support/runtime/arrays/array_set_mixed_key.rs index bb953a6477..7e860e8f8e 100644 --- a/src/codegen_support/runtime/arrays/array_set_mixed_key.rs +++ b/src/codegen_support/runtime/arrays/array_set_mixed_key.rs @@ -22,8 +22,16 @@ //! - The value is a boxed `Mixed` pointer consumed by the write (stored directly //! into the slot for the indexed path, or stored as a `Mixed`-tagged hash //! payload for the hash path), mirroring `__rt_array_set_mixed` ownership. -//! - The helper does not release the incoming array pointer; the caller owns the -//! old local release so the promoted hash can replace it cleanly. +//! - OWNERSHIP: the caller hands this helper an OWNED reference to the incoming +//! array (`lower_array_set_mixed_key_*` acquires it, mirroring `Op::ArrayToHash`). +//! The in-place paths hand that `+1` back inside the returned pointer; the promote +//! paths abandon the source for a freshly built hash and RELEASE it, because +//! `__rt_array_hash_union` only borrows its operands. The helper therefore always +//! returns a `+1` the caller owns, and `Op::ArraySetMixedKey` is classified as an +//! owning temporary so `store_local` releases whatever the slot held before. +//! Getting this wrong in either direction is fatal: releasing without the caller's +//! acquire is a use-after-free (the source's only reference lives in the caller's +//! slot); not releasing at all leaks the whole abandoned array on every promotion. use crate::codegen_support::abi; use crate::codegen_support::emit::Emitter; @@ -81,6 +89,24 @@ pub fn emit_array_set_mixed_key(emitter: &mut Emitter) { emitter.instruction("ldr x9, [x0]"); // load the current logical length of the indexed array emitter.instruction("cmp x1, x9"); // a key past the end would create a sparse gap emitter.instruction("b.hi __rt_array_set_mixed_key_int_promote"); // promote to a hash so a sparse key survives like PHP + + // -- widen typed slots to boxed Mixed BEFORE dropping a Mixed cell into one -- + // `__rt_array_set_mixed` re-stamps the destination's value_type to 7 (boxed Mixed) but never + // converts the slots that are already there. Writing into a still-typed array (`[1, 2, 3]`) + // therefore left slots 1 and 2 holding raw integers while the header claimed they were Mixed + // cell pointers — reading either one dereferenced the integer and segfaulted. Only element 0, + // the one just written, survived. Widen first, exactly as the Mixed-append lowering does. + emitter.instruction("str x1, [sp, #24]"); // save the target index across the widening call + emitter.instruction("ldr x1, [x0, #-8]"); // load the destination's packed metadata + emitter.instruction("lsr x1, x1, #8"); // move the runtime value_type tag into the low bits + emitter.instruction("and x1, x1, #0x7f"); // isolate the destination's value_type tag + emitter.instruction("cmp x1, #7"); // tag 7 = the slots already hold boxed Mixed cells + emitter.instruction("b.eq __rt_array_set_mixed_key_int_boxed"); // nothing to widen for an already-Mixed destination + emitter.instruction("bl __rt_array_to_mixed"); // box every existing typed slot and stamp the array Mixed + emitter.instruction("str x0, [sp, #0]"); // republish the possibly copy-on-write-split array pointer + emitter.label("__rt_array_set_mixed_key_int_boxed"); + emitter.instruction("ldr x0, [sp, #0]"); // reload the widened indexed-array pointer + emitter.instruction("ldr x1, [sp, #24]"); // reload the target index emitter.instruction("ldr x2, [sp, #16]"); // reload the consumed boxed Mixed value emitter.instruction("bl __rt_array_set_mixed"); // store the value into packed indexed storage and return the array emitter.instruction("b __rt_array_set_mixed_key_done"); // finish after an indexed write @@ -116,6 +142,8 @@ pub fn emit_array_set_mixed_key(emitter: &mut Emitter) { emitter.instruction("str x0, [sp, #48]"); // save the promoted merged hash pointer emitter.instruction("ldr x0, [sp, #40]"); // reload the temporary hash for release emitter.instruction("bl __rt_decref_hash"); // release the empty temporary hash after the union copy + emitter.instruction("ldr x0, [sp, #0]"); // reload the source indexed array this promotion abandons + emitter.instruction("bl __rt_decref_array"); // release the reference the lowering acquired: hash_union only BORROWED it emitter.instruction("ldr x0, [sp, #48]"); // reload the promoted merged hash for Mixed-box conversion emitter.instruction("bl __rt_hash_to_mixed"); // box union-copied scalar slots as Mixed cells so foreach readback is correct emitter.instruction("str x0, [sp, #48]"); // save the Mixed-boxed promoted hash pointer (ensure_unique may reallocate) @@ -210,8 +238,24 @@ fn emit_array_set_mixed_key_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov rcx, QWORD PTR [rax]"); // load the current logical length of the indexed array emitter.instruction("cmp rdi, rcx"); // a key past the end would create a sparse gap emitter.instruction("ja __rt_array_set_mixed_key_int_promote"); // promote to a hash so a sparse key survives like PHP - emitter.instruction("mov rsi, rdi"); // publish the integer key as the indexed-set index argument - emitter.instruction("mov rdi, rax"); // reload the indexed-array pointer into the set argument + + // -- widen typed slots to boxed Mixed BEFORE dropping a Mixed cell into one -- + // See the AArch64 twin: `__rt_array_set_mixed` re-stamps the destination Mixed but never + // converts the slots already in it, so a write into `[1, 2, 3]` left the untouched slots + // holding raw integers behind a header claiming they were cell pointers. Here `rdi` holds the + // integer key and `rax` the array — the opposite of the AArch64 register split. + emitter.instruction("mov QWORD PTR [rbp - 32], rdi"); // save the target index across the widening call + emitter.instruction("mov rsi, QWORD PTR [rax - 8]"); // load the destination's packed metadata + emitter.instruction("shr rsi, 8"); // move the runtime value_type tag into the low bits + emitter.instruction("and rsi, 0x7f"); // isolate the destination's value_type tag + emitter.instruction("cmp rsi, 7"); // tag 7 = the slots already hold boxed Mixed cells + emitter.instruction("je __rt_array_set_mixed_key_int_boxed"); // nothing to widen for an already-Mixed destination + emitter.instruction("mov rdi, rax"); // __rt_array_to_mixed takes the array in rdi and the tag in rsi + emitter.instruction("call __rt_array_to_mixed"); // box every existing typed slot and stamp the array Mixed + emitter.instruction("mov QWORD PTR [rbp - 8], rax"); // republish the possibly copy-on-write-split array pointer + emitter.label("__rt_array_set_mixed_key_int_boxed"); + emitter.instruction("mov rdi, QWORD PTR [rbp - 8]"); // reload the widened indexed-array pointer + emitter.instruction("mov rsi, QWORD PTR [rbp - 32]"); // reload the target index as the indexed-set index argument emitter.instruction("mov rdx, QWORD PTR [rbp - 24]"); // reload the consumed boxed Mixed value emitter.instruction("call __rt_array_set_mixed"); // store the value into packed indexed storage and return the array emitter.instruction("jmp __rt_array_set_mixed_key_done"); // finish after an indexed write @@ -246,6 +290,8 @@ fn emit_array_set_mixed_key_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov QWORD PTR [rbp - 56], rax"); // save the promoted merged hash pointer emitter.instruction("mov rax, QWORD PTR [rbp - 48]"); // reload the temporary hash for release emitter.instruction("call __rt_decref_hash"); // release the empty temporary hash after the union copy + emitter.instruction("mov rax, QWORD PTR [rbp - 8]"); // reload the source indexed array this promotion abandons + emitter.instruction("call __rt_decref_array"); // release the reference the lowering acquired: hash_union only BORROWED it emitter.instruction("mov rdi, QWORD PTR [rbp - 56]"); // reload the promoted merged hash for Mixed-box conversion emitter.instruction("call __rt_hash_to_mixed"); // box union-copied scalar slots as Mixed cells so foreach readback is correct emitter.instruction("mov QWORD PTR [rbp - 56], rax"); // save the Mixed-boxed promoted hash pointer (ensure_unique may reallocate) diff --git a/src/codegen_support/runtime/arrays/array_strict_eq.rs b/src/codegen_support/runtime/arrays/array_strict_eq.rs new file mode 100644 index 0000000000..a42c3d014b --- /dev/null +++ b/src/codegen_support/runtime/arrays/array_strict_eq.rs @@ -0,0 +1,362 @@ +//! Purpose: +//! Emits the `__rt_array_strict_eq`, `__rt_array_iter_next` runtime helper assembly for deep PHP +//! `===` structural equality of two arrays. +//! Keeps PHP array/hash storage, heap ownership, and target-specific ABI variants in one focused emitter. +//! +//! Called from: +//! - `crate::codegen_support::runtime::emitters::emit_runtime()` via `crate::codegen_support::runtime::arrays`. +//! - `__rt_mixed_strict_eq` dispatches array/hash payload tags (4/5) here so that `$a === $b` and +//! every nested array element compares by structure rather than heap-pointer identity. +//! +//! Key details: +//! - PHP `===` on arrays requires the same element count, the same key => value pairs in the same +//! insertion order, and each value strictly equal (recursively). Keys are strict-typed (an int key +//! never equals a string key). Two arrays stored in different representations (a packed indexed +//! array vs. a hash) still compare equal when they yield the same ordered (key, value) sequence, so +//! comparison is driven by a uniform logical iterator rather than by matching representations. +//! - The value comparison materializes each element as a temporary 24-byte Mixed cell on the stack +//! and delegates to `__rt_mixed_strict_eq`, which recurses back here for nested array values. No +//! heap allocation and no refcount mutation occur, so the comparison is ownership-neutral. +//! - Homogeneous packed scalar arrays store int/float/bool inline under one array-wide value_type +//! (0), so their elements compare by raw payload bits; heterogeneous arrays box each element +//! (value_type 7) and keep full per-element type precision. + +use crate::codegen_support::emit::Emitter; +use crate::codegen_support::platform::Arch; + +/// Emits `__rt_array_strict_eq` and its private `__rt_array_iter_next` helper for the host target. +/// +/// `__rt_array_strict_eq` implements deep PHP `===` for two arrays. It short-circuits on pointer +/// identity, rejects on unequal element counts, then walks both operands in lock-step through a +/// uniform logical iterator, comparing each key with `__rt_hash_key_eq` and each value through a +/// stack-materialized Mixed cell passed to `__rt_mixed_strict_eq` (which recurses back here for +/// nested arrays). `__rt_array_iter_next` abstracts the packed-array and hash representations behind +/// a single ordered `(next_cursor, key, value)` protocol. +/// +/// # Inputs / outputs (AArch64) +/// - `__rt_array_strict_eq`: `x0` = left array/hash pointer, `x1` = right array/hash pointer → +/// `x0` = 1 when deeply equal, 0 otherwise. +/// - `__rt_array_iter_next`: `x0` = array/hash pointer, `x1` = cursor (0 to start) → `x0` = next +/// cursor (`-1` when exhausted), `x1` = key low word, `x2` = key high word (`-1` for an int key), +/// `x3` = value tag, `x4` = value low word, `x5` = value high word. +/// +/// Delegates to `emit_array_strict_eq_linux_x86_64` on x86_64. +pub fn emit_array_strict_eq(emitter: &mut Emitter) { + if emitter.target.arch == Arch::X86_64 { + emit_array_strict_eq_linux_x86_64(emitter); + return; + } + + emit_array_strict_eq_aarch64(emitter); + emit_array_iter_next_aarch64(emitter); +} + +/// Emits the AArch64 `__rt_array_strict_eq` deep-equality driver. +fn emit_array_strict_eq_aarch64(emitter: &mut Emitter) { + emitter.blank(); + emitter.comment("--- runtime: array_strict_eq ---"); + emitter.label_global("__rt_array_strict_eq"); + + // -- frame: 128 bytes; slots for both Mixed value cells, the left key/cursor spills, and the + // callee-saved loop state preserved across the iterator/compare calls -- + emitter.instruction("sub sp, sp, #128"); // allocate the deep-compare stack frame + emitter.instruction("stp x29, x30, [sp, #112]"); // save frame pointer and return address + emitter.instruction("add x29, sp, #112"); // establish the helper stack frame + emitter.instruction("stp x19, x20, [sp, #80]"); // preserve callee-saved registers for the array pointers + emitter.instruction("stp x21, x22, [sp, #96]"); // preserve callee-saved registers for the two cursors + + emitter.instruction("mov x19, x0"); // x19 = left array/hash pointer + emitter.instruction("mov x20, x1"); // x20 = right array/hash pointer + emitter.instruction("cmp x0, x1"); // identical heap pointers are trivially strictly equal + emitter.instruction("b.eq __rt_array_strict_eq_true"); // short-circuit copy-on-write shared arrays + emitter.instruction("ldr x9, [x0]"); // x9 = left element count (header word 0) + emitter.instruction("ldr x10, [x1]"); // x10 = right element count (header word 0) + emitter.instruction("cmp x9, x10"); // arrays of different length are never strictly equal + emitter.instruction("b.ne __rt_array_strict_eq_false"); // reject on count mismatch + emitter.instruction("cbz x9, __rt_array_strict_eq_true"); // two empty arrays are strictly equal + emitter.instruction("mov x21, #0"); // x21 = left cursor (fresh walk) + emitter.instruction("mov x22, #0"); // x22 = right cursor (fresh walk) + + // -- lock-step walk of both operands -- + emitter.label("__rt_array_strict_eq_loop"); + emitter.instruction("mov x0, x19"); // iterate the left operand + emitter.instruction("mov x1, x21"); // from the left cursor + emitter.instruction("bl __rt_array_iter_next"); // x0=next, x1=key_lo, x2=key_hi, x3=tag, x4=lo, x5=hi + emitter.instruction("str x0, [sp, #64]"); // spill the left next cursor across the right iteration + emitter.instruction("stp x1, x2, [sp, #48]"); // spill the left key (lo, hi) for the key comparison + emitter.instruction("str x3, [sp, #0]"); // build the left Mixed cell: value tag + emitter.instruction("stp x4, x5, [sp, #8]"); // build the left Mixed cell: value low/high words + emitter.instruction("mov x0, x20"); // iterate the right operand + emitter.instruction("mov x1, x22"); // from the right cursor + emitter.instruction("bl __rt_array_iter_next"); // x0=next, x1=key_lo, x2=key_hi, x3=tag, x4=lo, x5=hi + emitter.instruction("ldr x9, [sp, #64]"); // reload the left next cursor + emitter.instruction("cmn x9, #1"); // is the left operand exhausted (next == -1)? + emitter.instruction("b.eq __rt_array_strict_eq_left_done"); // handle simultaneous termination + emitter.instruction("cmn x0, #1"); // the right operand ended before the left one? + emitter.instruction("b.eq __rt_array_strict_eq_false"); // unequal length despite the count pre-check + emitter.instruction("str x0, [sp, #72]"); // spill the right next cursor + emitter.instruction("str x3, [sp, #24]"); // build the right Mixed cell: value tag + emitter.instruction("stp x4, x5, [sp, #32]"); // build the right Mixed cell: value low/high words + emitter.instruction("mov x3, x1"); // right key low word into the key-eq third argument + emitter.instruction("mov x4, x2"); // right key high word into the key-eq fourth argument + emitter.instruction("ldp x1, x2, [sp, #48]"); // reload the left key (lo, hi) into the first two arguments + emitter.instruction("bl __rt_hash_key_eq"); // strict key comparison (int keys never equal string keys) + emitter.instruction("cbz x0, __rt_array_strict_eq_false"); // reject on differing keys + emitter.instruction("add x0, sp, #0"); // left Mixed cell address + emitter.instruction("add x1, sp, #24"); // right Mixed cell address + emitter.instruction("bl __rt_mixed_strict_eq"); // deep value comparison (recurses here for nested arrays) + emitter.instruction("cbz x0, __rt_array_strict_eq_false"); // reject on differing values + emitter.instruction("ldr x21, [sp, #64]"); // advance the left cursor + emitter.instruction("ldr x22, [sp, #72]"); // advance the right cursor + emitter.instruction("b __rt_array_strict_eq_loop"); // continue the lock-step walk + + emitter.label("__rt_array_strict_eq_left_done"); + emitter.instruction("cmn x0, #1"); // the left ended; the right must end simultaneously + emitter.instruction("b.eq __rt_array_strict_eq_true"); // both exhausted together -> strictly equal + emitter.instruction("b __rt_array_strict_eq_false"); // right still has entries -> unequal + + emitter.label("__rt_array_strict_eq_true"); + emitter.instruction("mov x0, #1"); // report strict structural equality + emitter.instruction("b __rt_array_strict_eq_done"); // fall through to the shared epilogue + + emitter.label("__rt_array_strict_eq_false"); + emitter.instruction("mov x0, #0"); // report that the arrays are not strictly equal + + emitter.label("__rt_array_strict_eq_done"); + emitter.instruction("ldp x21, x22, [sp, #96]"); // restore the cursor callee-saved registers + emitter.instruction("ldp x19, x20, [sp, #80]"); // restore the array-pointer callee-saved registers + emitter.instruction("ldp x29, x30, [sp, #112]"); // restore frame pointer and return address + emitter.instruction("add sp, sp, #128"); // release the deep-compare stack frame + emitter.instruction("ret"); // return the strict-equality boolean in x0 +} + +/// Emits the AArch64 `__rt_array_iter_next` uniform logical iterator. +fn emit_array_iter_next_aarch64(emitter: &mut Emitter) { + emitter.blank(); + emitter.comment("--- runtime: array_iter_next ---"); + emitter.label_global("__rt_array_iter_next"); + + emitter.instruction("ldr x9, [x0, #-8]"); // load the heap kind word + emitter.instruction("and x9, x9, #0xff"); // isolate the low heap-kind byte (2 = packed, 3 = hash) + emitter.instruction("cmp x9, #3"); // is the operand a hash (associative) container? + emitter.instruction("b.eq __rt_array_iter_next_hash"); // hashes delegate to the ordered hash iterator + + // -- packed indexed array: keys are the sequential indices 0..len-1 -- + emitter.instruction("ldr x10, [x0]"); // x10 = packed length (header word 0) + emitter.instruction("cmp x1, x10"); // has the cursor walked past the last element? + emitter.instruction("b.ge __rt_array_iter_next_done"); // exhausted packed arrays report the done sentinel + emitter.instruction("ldr x11, [x0, #-8]"); // reload the kind word for the value_type field + emitter.instruction("lsr x11, x11, #8"); // shift the array-wide value_type into the low bits + emitter.instruction("and x11, x11, #0x7f"); // isolate the value_type, dropping the copy-on-write bit + emitter.instruction("add x12, x0, #24"); // x12 = base of the packed data region (skip 24-byte header) + emitter.instruction("add x13, x1, #1"); // x13 = next cursor = index + 1 + emitter.instruction("cmp x11, #1"); // is the array a string array (16-byte {ptr,len} slots)? + emitter.instruction("b.eq __rt_array_iter_next_packed_str"); // strings load a pointer/length pair + emitter.instruction("cmp x11, #11"); // is the array a tagged-scalar array (per-slot tag)? + emitter.instruction("b.eq __rt_array_iter_next_packed_tagged"); // tagged scalars carry a per-slot runtime tag + + // -- 8-byte-slot value types (int/float/bool inline, or array/object/mixed/callable pointer) -- + emitter.instruction("lsl x14, x1, #3"); // byte offset = index * 8 + emitter.instruction("ldr x4, [x12, x14]"); // value low word = data[index] + emitter.instruction("mov x5, #0"); // value high word unused for 8-byte slots + emitter.instruction("mov x3, x11"); // value tag = the array-wide value_type + emitter.instruction("b __rt_array_iter_next_packed_ret"); // return the synthesized packed entry + + emitter.label("__rt_array_iter_next_packed_str"); + emitter.instruction("lsl x14, x1, #4"); // byte offset = index * 16 + emitter.instruction("add x14, x12, x14"); // address of the string slot + emitter.instruction("ldr x4, [x14]"); // value low word = string pointer + emitter.instruction("ldr x5, [x14, #8]"); // value high word = string length + emitter.instruction("mov x3, #1"); // value tag = string + emitter.instruction("b __rt_array_iter_next_packed_ret"); // return the synthesized packed string entry + + emitter.label("__rt_array_iter_next_packed_tagged"); + emitter.instruction("lsl x14, x1, #4"); // byte offset = index * 16 + emitter.instruction("add x14, x12, x14"); // address of the tagged-scalar slot + emitter.instruction("ldr x4, [x14]"); // value low word = tagged-scalar payload + emitter.instruction("ldr x3, [x14, #8]"); // value tag = the per-slot runtime tag + emitter.instruction("mov x5, #0"); // value high word unused for tagged scalars + emitter.instruction("b __rt_array_iter_next_packed_ret"); // return the synthesized tagged-scalar entry + + emitter.label("__rt_array_iter_next_packed_ret"); + emitter.instruction("mov x0, x13"); // x0 = next cursor + emitter.instruction("mov x2, #-1"); // key high word = -1 marks an integer key + emitter.instruction("ret"); // x1 already holds the integer key = the index + + emitter.label("__rt_array_iter_next_done"); + emitter.instruction("mov x0, #-1"); // report the done sentinel (no more entries) + emitter.instruction("ret"); // return to the lock-step driver + + // -- hash container: reuse the ordered insertion-order iterator and remap its registers -- + emitter.label("__rt_array_iter_next_hash"); + emitter.instruction("stp x29, x30, [sp, #-16]!"); // preserve the caller frame before the nested call + emitter.instruction("mov x29, sp"); // establish a minimal frame for the hash iterator call + emitter.instruction("bl __rt_hash_iter_next"); // x0=next, x1=key_ptr, x2=key_len, x3=lo, x4=hi, x5=tag + emitter.instruction("ldp x29, x30, [sp], #16"); // restore the caller frame after the hash iterator returns + emitter.instruction("mov x9, x5"); // stash the hash value tag before remapping + emitter.instruction("mov x10, x3"); // stash the hash value low word before remapping + emitter.instruction("mov x11, x4"); // stash the hash value high word before remapping + emitter.instruction("mov x3, x9"); // value tag into the uniform slot + emitter.instruction("mov x4, x10"); // value low word into the uniform slot + emitter.instruction("mov x5, x11"); // value high word into the uniform slot + emitter.instruction("ret"); // x0/x1/x2 already carry next cursor and key (lo, hi) +} + +/// Emits the x86_64 Linux `__rt_array_strict_eq` and `__rt_array_iter_next` helpers. +/// +/// Mirrors the AArch64 semantics using the System V AMD64 ABI: `rdi`/`rsi` carry the two array +/// pointers (or the array pointer and cursor), and the boolean / iterator results return in `rax` +/// (plus `rcx`/`rdx`/`r8`/`r9`/`r10` for the iterator's key and value words). Loop state is held in +/// callee-saved `r12`–`r15` across the iterator, key-eq, and value-eq calls. +fn emit_array_strict_eq_linux_x86_64(emitter: &mut Emitter) { + emitter.blank(); + emitter.comment("--- runtime: array_strict_eq ---"); + emitter.label_global("__rt_array_strict_eq"); + + emitter.instruction("push rbp"); // preserve the caller frame pointer + emitter.instruction("mov rbp, rsp"); // establish a stable frame base + emitter.instruction("push r12"); // preserve callee-saved r12 (left array pointer) + emitter.instruction("push r13"); // preserve callee-saved r13 (right array pointer) + emitter.instruction("push r14"); // preserve callee-saved r14 (left cursor) + emitter.instruction("push r15"); // preserve callee-saved r15 (right cursor) + emitter.instruction("sub rsp, 96"); // reserve the Mixed value cells and key/cursor spill slots + + emitter.instruction("mov r12, rdi"); // r12 = left array/hash pointer + emitter.instruction("mov r13, rsi"); // r13 = right array/hash pointer + emitter.instruction("cmp rdi, rsi"); // identical heap pointers are trivially strictly equal + emitter.instruction("je __rt_array_strict_eq_true"); // short-circuit copy-on-write shared arrays + emitter.instruction("mov rax, QWORD PTR [rdi]"); // rax = left element count (header word 0) + emitter.instruction("cmp rax, QWORD PTR [rsi]"); // arrays of different length are never strictly equal + emitter.instruction("jne __rt_array_strict_eq_false"); // reject on count mismatch + emitter.instruction("test rax, rax"); // are both operands empty? + emitter.instruction("je __rt_array_strict_eq_true"); // two empty arrays are strictly equal + emitter.instruction("xor r14, r14"); // r14 = left cursor (fresh walk) + emitter.instruction("xor r15, r15"); // r15 = right cursor (fresh walk) + + // Mixed-cell / spill layout relative to rsp: left cell [rsp+0..23], right cell [rsp+24..47], + // left key [rsp+48..63], left next cursor [rsp+64], right next cursor [rsp+72]. + emitter.label("__rt_array_strict_eq_loop"); + emitter.instruction("mov rdi, r12"); // iterate the left operand + emitter.instruction("mov rsi, r14"); // from the left cursor + emitter.instruction("call __rt_array_iter_next"); // rax=next, rcx=key_lo, rdx=key_hi, r8=tag, r9=lo, r10=hi + emitter.instruction("mov QWORD PTR [rsp + 64], rax"); // spill the left next cursor + emitter.instruction("mov QWORD PTR [rsp + 48], rcx"); // spill the left key low word + emitter.instruction("mov QWORD PTR [rsp + 56], rdx"); // spill the left key high word + emitter.instruction("mov QWORD PTR [rsp + 0], r8"); // build the left Mixed cell: value tag + emitter.instruction("mov QWORD PTR [rsp + 8], r9"); // build the left Mixed cell: value low word + emitter.instruction("mov QWORD PTR [rsp + 16], r10"); // build the left Mixed cell: value high word + emitter.instruction("mov rdi, r13"); // iterate the right operand + emitter.instruction("mov rsi, r15"); // from the right cursor + emitter.instruction("call __rt_array_iter_next"); // rax=next, rcx=key_lo, rdx=key_hi, r8=tag, r9=lo, r10=hi + emitter.instruction("mov r11, QWORD PTR [rsp + 64]"); // reload the left next cursor + emitter.instruction("cmp r11, -1"); // is the left operand exhausted? + emitter.instruction("je __rt_array_strict_eq_left_done"); // handle simultaneous termination + emitter.instruction("cmp rax, -1"); // the right operand ended before the left one? + emitter.instruction("je __rt_array_strict_eq_false"); // unequal length despite the count pre-check + emitter.instruction("mov QWORD PTR [rsp + 72], rax"); // spill the right next cursor + emitter.instruction("mov QWORD PTR [rsp + 24], r8"); // build the right Mixed cell: value tag + emitter.instruction("mov QWORD PTR [rsp + 32], r9"); // build the right Mixed cell: value low word + emitter.instruction("mov QWORD PTR [rsp + 40], r10"); // build the right Mixed cell: value high word + emitter.instruction("xchg rcx, rdx"); // swap so rdx = right key low, rcx = right key high + emitter.instruction("mov rdi, QWORD PTR [rsp + 48]"); // key-eq first argument = left key low word + emitter.instruction("mov rsi, QWORD PTR [rsp + 56]"); // key-eq second argument = left key high word + emitter.instruction("call __rt_hash_key_eq"); // strict key comparison (int keys never equal string keys) + emitter.instruction("test rax, rax"); // did the keys differ? + emitter.instruction("je __rt_array_strict_eq_false"); // reject on differing keys + emitter.instruction("lea rdi, [rsp + 0]"); // left Mixed cell address + emitter.instruction("lea rsi, [rsp + 24]"); // right Mixed cell address + emitter.instruction("call __rt_mixed_strict_eq"); // deep value comparison (recurses here for nested arrays) + emitter.instruction("test rax, rax"); // did the values differ? + emitter.instruction("je __rt_array_strict_eq_false"); // reject on differing values + emitter.instruction("mov r14, QWORD PTR [rsp + 64]"); // advance the left cursor + emitter.instruction("mov r15, QWORD PTR [rsp + 72]"); // advance the right cursor + emitter.instruction("jmp __rt_array_strict_eq_loop"); // continue the lock-step walk + + emitter.label("__rt_array_strict_eq_left_done"); + emitter.instruction("cmp rax, -1"); // the left ended; the right must end simultaneously + emitter.instruction("je __rt_array_strict_eq_true"); // both exhausted together -> strictly equal + emitter.instruction("jmp __rt_array_strict_eq_false"); // right still has entries -> unequal + + emitter.label("__rt_array_strict_eq_true"); + emitter.instruction("mov eax, 1"); // report strict structural equality + emitter.instruction("jmp __rt_array_strict_eq_done"); // fall through to the shared epilogue + + emitter.label("__rt_array_strict_eq_false"); + emitter.instruction("xor eax, eax"); // report that the arrays are not strictly equal + + emitter.label("__rt_array_strict_eq_done"); + emitter.instruction("add rsp, 96"); // release the Mixed value cells and spill slots + emitter.instruction("pop r15"); // restore callee-saved r15 + emitter.instruction("pop r14"); // restore callee-saved r14 + emitter.instruction("pop r13"); // restore callee-saved r13 + emitter.instruction("pop r12"); // restore callee-saved r12 + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return the strict-equality boolean in rax + + // -- uniform logical iterator -- + emitter.blank(); + emitter.comment("--- runtime: array_iter_next ---"); + emitter.label_global("__rt_array_iter_next"); + + emitter.instruction("mov rax, QWORD PTR [rdi - 8]"); // load the heap kind word + emitter.instruction("and rax, 0xff"); // isolate the low heap-kind byte (2 = packed, 3 = hash) + emitter.instruction("cmp rax, 3"); // is the operand a hash (associative) container? + emitter.instruction("je __rt_array_iter_next_hash"); // hashes delegate to the ordered hash iterator + + emitter.instruction("mov rax, QWORD PTR [rdi]"); // rax = packed length (header word 0) + emitter.instruction("cmp rsi, rax"); // has the cursor walked past the last element? + emitter.instruction("jge __rt_array_iter_next_done"); // exhausted packed arrays report the done sentinel + emitter.instruction("mov r11, QWORD PTR [rdi - 8]"); // reload the kind word for the value_type field + emitter.instruction("shr r11, 8"); // shift the array-wide value_type into the low bits + emitter.instruction("and r11, 0x7f"); // isolate the value_type, dropping the copy-on-write bit + emitter.instruction("mov rcx, rsi"); // key low word = the integer index + emitter.instruction("mov rdx, -1"); // key high word = -1 marks an integer key + emitter.instruction("cmp r11, 1"); // is the array a string array (16-byte {ptr,len} slots)? + emitter.instruction("je __rt_array_iter_next_packed_str"); // strings load a pointer/length pair + emitter.instruction("cmp r11, 11"); // is the array a tagged-scalar array (per-slot tag)? + emitter.instruction("je __rt_array_iter_next_packed_tagged"); // tagged scalars carry a per-slot runtime tag + + emitter.instruction("mov r8, r11"); // value tag = the array-wide value_type + emitter.instruction("mov r11, rsi"); // scratch index for the byte-offset computation + emitter.instruction("shl r11, 3"); // byte offset = index * 8 + emitter.instruction("add r11, rdi"); // address of the 8-byte slot within the data region + emitter.instruction("mov r9, QWORD PTR [r11 + 24]"); // value low word = data[index] (past the 24-byte header) + emitter.instruction("xor r10d, r10d"); // value high word unused for 8-byte slots + emitter.instruction("jmp __rt_array_iter_next_packed_ret"); // return the synthesized packed entry + + emitter.label("__rt_array_iter_next_packed_str"); + emitter.instruction("mov r11, rsi"); // scratch index for the byte-offset computation + emitter.instruction("shl r11, 4"); // byte offset = index * 16 + emitter.instruction("add r11, rdi"); // address of the string slot base + emitter.instruction("mov r9, QWORD PTR [r11 + 24]"); // value low word = string pointer + emitter.instruction("mov r10, QWORD PTR [r11 + 32]"); // value high word = string length + emitter.instruction("mov r8, 1"); // value tag = string + emitter.instruction("jmp __rt_array_iter_next_packed_ret"); // return the synthesized packed string entry + + emitter.label("__rt_array_iter_next_packed_tagged"); + emitter.instruction("mov r11, rsi"); // scratch index for the byte-offset computation + emitter.instruction("shl r11, 4"); // byte offset = index * 16 + emitter.instruction("add r11, rdi"); // address of the tagged-scalar slot base + emitter.instruction("mov r9, QWORD PTR [r11 + 24]"); // value low word = tagged-scalar payload + emitter.instruction("mov r8, QWORD PTR [r11 + 32]"); // value tag = the per-slot runtime tag + emitter.instruction("xor r10d, r10d"); // value high word unused for tagged scalars + emitter.instruction("jmp __rt_array_iter_next_packed_ret"); // return the synthesized tagged-scalar entry + + emitter.label("__rt_array_iter_next_packed_ret"); + emitter.instruction("lea rax, [rsi + 1]"); // next cursor = index + 1 + emitter.instruction("ret"); // rcx/rdx carry the integer key; r8/r9/r10 the value + + emitter.label("__rt_array_iter_next_done"); + emitter.instruction("mov rax, -1"); // report the done sentinel (no more entries) + emitter.instruction("ret"); // return to the lock-step driver + + emitter.label("__rt_array_iter_next_hash"); + emitter.instruction("sub rsp, 8"); // align the stack to 16 bytes for the SysV call + emitter.instruction("call __rt_hash_iter_next"); // rdi=hash, rsi=cursor -> rax=next, rdi=key_ptr, rdx=key_len, rcx=lo, r8=hi, r9=tag + emitter.instruction("add rsp, 8"); // restore the stack pointer after the call + emitter.instruction("mov r10, r8"); // value high word from the hash entry + emitter.instruction("mov r8, r9"); // value tag from the hash entry + emitter.instruction("mov r9, rcx"); // value low word from the hash entry + emitter.instruction("mov rcx, rdi"); // key low word = the entry key pointer / int value + emitter.instruction("ret"); // rax=next cursor, rdx already holds the key high word +} diff --git a/src/codegen_support/runtime/arrays/decref_array.rs b/src/codegen_support/runtime/arrays/decref_array.rs index 9dacaa0b43..d9d8cadf99 100644 --- a/src/codegen_support/runtime/arrays/decref_array.rs +++ b/src/codegen_support/runtime/arrays/decref_array.rs @@ -39,6 +39,14 @@ pub fn emit_decref_array(emitter: &mut Emitter) { emitter.instruction("cmp x0, x10"); // is pointer at or beyond heap end? emitter.instruction("b.hs __rt_decref_array_skip"); // yes — not a valid heap pointer, skip + // -- a statically indexed array may have been promoted to hash storage by a Mixed key -- + emitter.instruction("ldr x11, [x0, #-8]"); // load the uniform heap kind word before choosing the deep-free contract + emitter.instruction("and x11, x11, #0xff"); // isolate the low-byte runtime storage kind + emitter.instruction("cmp x11, #3"); // kind 3 is associative hash storage + emitter.instruction("b.ne __rt_decref_array_kind_checked"); // ordinary indexed storage keeps the indexed release path + emitter.instruction("b __rt_decref_hash"); // release promoted storage with the hash entry walker + emitter.label("__rt_decref_array_kind_checked"); + // -- debug mode: reject decref on freed storage -- crate::codegen_support::abi::emit_symbol_address(emitter, "x9", "_heap_debug_enabled"); emitter.instruction("ldr x9, [x9]"); // load the heap-debug enabled flag @@ -86,9 +94,13 @@ fn emit_decref_array_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("cmp rax, r11"); // is the candidate array pointer outside the live heap window? emitter.instruction("jae __rt_decref_array_skip"); // pointers above the live heap end are not refcounted arrays emitter.instruction("mov r10, QWORD PTR [rax - 8]"); // load the stamped x86_64 heap kind word from the uniform header + emitter.instruction("mov r11, r10"); // preserve the low-byte runtime kind before checking the high-word marker emitter.instruction("shr r10, 32"); // isolate the high-word heap marker used by the x86_64 heap wrapper emitter.instruction(&format!("cmp r10d, 0x{:x}", crate::codegen_support::sentinels::X86_64_HEAP_MAGIC_HI32)); // verify that the payload is owned by the x86_64 heap wrapper before mutating refcount state emitter.instruction("jne __rt_decref_array_skip"); // skip foreign/static pointers that do not carry elephc heap headers + emitter.instruction("and r11, 0xff"); // isolate the runtime storage kind from the preserved header word + emitter.instruction("cmp r11, 3"); // kind 3 is associative hash storage behind a static Array type + emitter.instruction("je __rt_decref_hash"); // release promoted storage with the hash entry walker emitter.instruction("mov r10d, DWORD PTR [rax - 12]"); // load the 32-bit refcount stored in the uniform heap header emitter.instruction("sub r10d, 1"); // decrement the refcount for the array owner that is going away emitter.instruction("mov DWORD PTR [rax - 12], r10d"); // persist the decremented array refcount in the uniform heap header diff --git a/src/codegen_support/runtime/arrays/gc_collect_cycles.rs b/src/codegen_support/runtime/arrays/gc_collect_cycles.rs index 4c8cfe45be..50c94e0fa1 100644 --- a/src/codegen_support/runtime/arrays/gc_collect_cycles.rs +++ b/src/codegen_support/runtime/arrays/gc_collect_cycles.rs @@ -187,14 +187,19 @@ pub fn emit_gc_collect_cycles(emitter: &mut Emitter) { emitter.instruction("b __rt_gc_collect_cycles_count_next"); // mixed-child counting is complete emitter.label("__rt_gc_collect_cycles_count_object"); - emitter.instruction("ldr w13, [x9]"); // load the object payload size from the heap header - emitter.instruction("sub x13, x13, #8"); // subtract the leading class_id field - emitter.instruction("lsr x13, x13, #4"); // divide by 16 to get the number of property slots emitter.instruction("ldr x14, [x12]"); // load the runtime class_id from the object payload crate::codegen_support::abi::emit_symbol_address(emitter, "x15", "_class_gc_desc_count"); emitter.instruction("ldr x15, [x15]"); // load the number of emitted class descriptors emitter.instruction("cmp x14, x15"); // is the class_id within range? emitter.instruction("b.hs __rt_gc_collect_cycles_count_next"); // invalid class ids contribute no traversable edges + emitter.instruction("lsl x10, x14, #3"); // scale the class id by eight bytes for class-layout tables + crate::codegen_support::abi::emit_symbol_address(emitter, "x15", "_class_object_payload_sizes"); + emitter.instruction("ldr x13, [x15, x10]"); // load the class-declared payload size, not reused heap capacity + crate::codegen_support::abi::emit_symbol_address(emitter, "x15", "_class_object_dynamic_prop_flags"); + emitter.instruction("ldr x15, [x15, x10]"); // load whether the layout includes a dynamic-property tail + emitter.instruction("sub x13, x13, #8"); // subtract the leading class_id field + emitter.instruction("sub x13, x13, x15, lsl #3"); // exclude the optional eight-byte dynamic-property tail + emitter.instruction("lsr x13, x13, #4"); // divide the fixed property region by 16 bytes per slot crate::codegen_support::abi::emit_symbol_address(emitter, "x15", "_class_gc_desc_ptrs"); emitter.instruction("lsl x14, x14, #3"); // scale class_id by 8 bytes per descriptor pointer emitter.instruction("ldr x14, [x15, x14]"); // load the property-tag descriptor pointer diff --git a/src/codegen_support/runtime/arrays/gc_collect_cycles_x86_64.rs b/src/codegen_support/runtime/arrays/gc_collect_cycles_x86_64.rs index 790b64ea06..ef986fa85d 100644 --- a/src/codegen_support/runtime/arrays/gc_collect_cycles_x86_64.rs +++ b/src/codegen_support/runtime/arrays/gc_collect_cycles_x86_64.rs @@ -196,14 +196,19 @@ pub(super) fn emit_gc_collect_cycles_linux_x86_64(emitter: &mut Emitter) { emitter.label("__rt_gc_collect_cycles_count_object"); emitter.instruction("lea r9, [rdx + 16]"); // compute the source object user pointer from its heap header - emitter.instruction("mov eax, DWORD PTR [rdx]"); // load the source object payload size before deriving the property count - emitter.instruction("sub rax, 8"); // subtract the leading class_id field from the object payload size - emitter.instruction("shr rax, 4"); // divide by 16 to get the source object property count emitter.instruction("mov r10, QWORD PTR [r9]"); // load the runtime class_id stored at the start of the source object payload crate::codegen_support::abi::emit_symbol_address(emitter, "r11", "_class_gc_desc_count"); emitter.instruction("mov r11, QWORD PTR [r11]"); // load the number of emitted class GC descriptors for bounds checking emitter.instruction("cmp r10, r11"); // is the runtime class_id within the emitted descriptor table range? emitter.instruction("jae __rt_gc_collect_cycles_count_next"); // invalid class ids contribute no traversable property metadata + crate::codegen_support::abi::emit_symbol_address(emitter, "r11", "_class_object_payload_sizes"); + emitter.instruction("mov rax, QWORD PTR [r11 + r10 * 8]"); // load the class-declared payload size, not reused heap capacity + crate::codegen_support::abi::emit_symbol_address(emitter, "r11", "_class_object_dynamic_prop_flags"); + emitter.instruction("mov rcx, QWORD PTR [r11 + r10 * 8]"); // load whether the layout includes a dynamic-property tail + emitter.instruction("sub rax, 8"); // subtract the leading class_id field + emitter.instruction("shl rcx, 3"); // convert the tail flag into its eight-byte storage size + emitter.instruction("sub rax, rcx"); // exclude the optional tail from the fixed property region + emitter.instruction("shr rax, 4"); // divide the fixed property region by 16 bytes per slot crate::codegen_support::abi::emit_symbol_address(emitter, "r11", "_class_gc_desc_ptrs"); emitter.instruction("mov r11, QWORD PTR [r11 + r10 * 8]"); // load the per-class property-tag descriptor pointer for the source object emitter.instruction("xor r10, r10"); // initialize the source object property index to zero for the incoming-edge scan diff --git a/src/codegen_support/runtime/arrays/gc_mark_reachable.rs b/src/codegen_support/runtime/arrays/gc_mark_reachable.rs index 0709d25bc8..27c096bee9 100644 --- a/src/codegen_support/runtime/arrays/gc_mark_reachable.rs +++ b/src/codegen_support/runtime/arrays/gc_mark_reachable.rs @@ -183,15 +183,19 @@ pub fn emit_gc_mark_reachable(emitter: &mut Emitter) { // -- object traversal: consult the emitted per-class property descriptor table -- emitter.label("__rt_gc_mark_reachable_object"); - emitter.instruction("ldr w9, [x0, #-16]"); // load the object payload size from the heap header - emitter.instruction("sub x9, x9, #8"); // subtract the leading class_id field - emitter.instruction("lsr x9, x9, #4"); // divide by 16 to get the property count - emitter.instruction("str x9, [sp, #16]"); // save the property count for the loop bound emitter.instruction("ldr x10, [x0]"); // load the runtime class_id from the object payload crate::codegen_support::abi::emit_symbol_address(emitter, "x11", "_class_gc_desc_count"); emitter.instruction("ldr x11, [x11]"); // load the number of emitted class descriptors emitter.instruction("cmp x10, x11"); // is the class_id within range? emitter.instruction("b.hs __rt_gc_mark_reachable_return"); // invalid class ids contribute no traversable edges + crate::codegen_support::abi::emit_symbol_address(emitter, "x11", "_class_object_payload_sizes"); + emitter.instruction("ldr x9, [x11, x10, lsl #3]"); // load the class-declared payload size, not reused heap capacity + crate::codegen_support::abi::emit_symbol_address(emitter, "x11", "_class_object_dynamic_prop_flags"); + emitter.instruction("ldr x11, [x11, x10, lsl #3]"); // load whether the layout includes a dynamic-property tail + emitter.instruction("sub x9, x9, #8"); // subtract the leading class_id field + emitter.instruction("sub x9, x9, x11, lsl #3"); // exclude the optional eight-byte dynamic-property tail + emitter.instruction("lsr x9, x9, #4"); // divide the fixed property region by 16 bytes per slot + emitter.instruction("str x9, [sp, #16]"); // save the authoritative property count for the loop bound crate::codegen_support::abi::emit_symbol_address(emitter, "x11", "_class_gc_desc_ptrs"); emitter.instruction("lsl x12, x10, #3"); // scale class_id by 8 bytes per descriptor pointer emitter.instruction("ldr x11, [x11, x12]"); // load the property-tag descriptor pointer @@ -384,15 +388,20 @@ fn emit_gc_mark_reachable_linux_x86_64(emitter: &mut Emitter) { // -- object traversal: consult the emitted per-class property descriptor table -- emitter.label("__rt_gc_mark_reachable_object"); emitter.instruction("mov rdx, QWORD PTR [rbp - 8]"); // reload the current object pointer before computing its property count - emitter.instruction("mov ecx, DWORD PTR [rdx - 16]"); // load the object payload size from the uniform heap header - emitter.instruction("sub rcx, 8"); // subtract the leading class_id field from the object payload size - emitter.instruction("shr rcx, 4"); // divide by 16 to get the number of property slots in this object layout - emitter.instruction("mov QWORD PTR [rbp - 24], rcx"); // save the property count for the object traversal loop bound emitter.instruction("mov rcx, QWORD PTR [rdx]"); // load the runtime class_id stored at the start of the object payload crate::codegen_support::abi::emit_symbol_address(emitter, "r8", "_class_gc_desc_count"); emitter.instruction("mov r8, QWORD PTR [r8]"); // load the number of emitted class GC descriptors emitter.instruction("cmp rcx, r8"); // is the runtime class_id within the emitted descriptor table range? emitter.instruction("jae __rt_gc_mark_reachable_return"); // invalid class ids contribute no traversable property metadata + crate::codegen_support::abi::emit_symbol_address(emitter, "r8", "_class_object_payload_sizes"); + emitter.instruction("mov rax, QWORD PTR [r8 + rcx * 8]"); // load the class-declared payload size, not reused heap capacity + crate::codegen_support::abi::emit_symbol_address(emitter, "r8", "_class_object_dynamic_prop_flags"); + emitter.instruction("mov r9, QWORD PTR [r8 + rcx * 8]"); // load whether the layout includes a dynamic-property tail + emitter.instruction("sub rax, 8"); // subtract the leading class_id field + emitter.instruction("shl r9, 3"); // convert the tail flag into its eight-byte storage size + emitter.instruction("sub rax, r9"); // exclude the optional tail from the fixed property region + emitter.instruction("shr rax, 4"); // divide the fixed property region by 16 bytes per slot + emitter.instruction("mov QWORD PTR [rbp - 24], rax"); // save the authoritative property count for the loop bound crate::codegen_support::abi::emit_symbol_address(emitter, "r8", "_class_gc_desc_ptrs"); emitter.instruction("mov r9, QWORD PTR [r8 + rcx * 8]"); // load the per-class property-tag descriptor pointer for this object instance emitter.instruction("mov QWORD PTR [rbp - 40], r9"); // save the descriptor pointer across recursive property traversals diff --git a/src/codegen_support/runtime/arrays/hash_clone_shallow.rs b/src/codegen_support/runtime/arrays/hash_clone_shallow.rs index 942f720a8e..0844b2932b 100644 --- a/src/codegen_support/runtime/arrays/hash_clone_shallow.rs +++ b/src/codegen_support/runtime/arrays/hash_clone_shallow.rs @@ -92,6 +92,8 @@ pub fn emit_hash_clone_shallow(emitter: &mut Emitter) { emitter.instruction("b.eq __rt_hash_clone_shallow_value_ref"); // nested refcounted values need retains emitter.instruction("cmp x5, #7"); // is this entry's value a boxed mixed cell? emitter.instruction("b.eq __rt_hash_clone_shallow_value_ref"); // nested refcounted values need retains + emitter.instruction("cmp x5, #10"); // is this entry's value a callable descriptor? + emitter.instruction("b.eq __rt_hash_clone_shallow_value_ref"); // runtime descriptors need retains; static descriptors are ignored by incref emitter.instruction("ldr x3, [sp, #24]"); // x3 = scalar/float value_lo copied as-is emitter.instruction("ldr x4, [sp, #32]"); // x4 = scalar/float value_hi copied as-is emitter.instruction("ldr x5, [sp, #40]"); // x5 = scalar/float/null value_tag copied as-is @@ -196,6 +198,8 @@ fn emit_hash_clone_shallow_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("je __rt_hash_clone_shallow_value_ref"); // retain nested refcounted child pointers for the cloned associative-array owner emitter.instruction("cmp r10, 7"); // is the current source entry value a boxed mixed child pointer that needs a retain? emitter.instruction("je __rt_hash_clone_shallow_value_ref"); // retain nested refcounted child pointers for the cloned associative-array owner + emitter.instruction("cmp r10, 10"); // is the current source entry value a callable descriptor that needs a retain? + emitter.instruction("je __rt_hash_clone_shallow_value_ref"); // retain runtime descriptors while static descriptor pointers remain unchanged emitter.instruction("mov rcx, QWORD PTR [rbp - 48]"); // reload the scalar or float low payload word that can be forwarded into the destination hash unchanged emitter.instruction("mov r8, QWORD PTR [rbp - 56]"); // reload the scalar or float high payload word that can be forwarded into the destination hash unchanged emitter.instruction("mov r9, QWORD PTR [rbp - 64]"); // reload the scalar or float runtime value_tag that can be forwarded into the destination hash unchanged diff --git a/src/codegen_support/runtime/arrays/heap_alloc.rs b/src/codegen_support/runtime/arrays/heap_alloc.rs index 8b5cfa2f11..ea66217cd0 100644 --- a/src/codegen_support/runtime/arrays/heap_alloc.rs +++ b/src/codegen_support/runtime/arrays/heap_alloc.rs @@ -21,6 +21,12 @@ use crate::codegen_support::platform::Arch; /// Each block carries a 16-byte header `[size:4][refcount:4][kind:8]` before the user pointer. /// Free blocks reuse the same header layout plus a `next_ptr:8` word for list chaining. /// +/// The small-bin fast path validates every candidate before reuse with the same discipline +/// as the general free list: the header must lie inside the live heap window (else the chain +/// is truncated, since its next link cannot be trusted), and a parked block must be free +/// (`refcount == 0` and no retained `kind`, else it is unlinked as poison). Under `--heap-debug` +/// the same poison is caught loudly at alloc entry by `__rt_heap_debug_validate_free_list`. +/// /// Input: `x0` (ARM) / `rax` (x86_64) = requested payload bytes (minimum 8 enforced). /// Output: `x0` / `rax` = user pointer (header + 16). /// @@ -72,7 +78,27 @@ pub fn emit_heap_alloc(emitter: &mut Emitter) { emitter.label("__rt_heap_alloc_small_bin_scan"); emitter.instruction("ldr x10, [x16]"); // x10 = current cached block header or null when this bin is exhausted emitter.instruction("cbz x10, __rt_heap_alloc_small_bin_next_class"); // try the next larger bin when this bin has no fitting block + // -- reject cached entries that escaped the live heap window before dereferencing them -- + crate::codegen_support::abi::emit_symbol_address(emitter, "x12", "_heap_buf"); + emitter.instruction("cmp x10, x12"); // does the cached block point below the heap buffer base? + emitter.instruction("b.lo __rt_heap_alloc_small_bin_drop_tail"); // wild pointer: truncate the chain, its next link cannot be trusted + crate::codegen_support::abi::emit_symbol_address(emitter, "x14", "_heap_off"); + emitter.instruction("ldr x14, [x14]"); // load the current heap bump offset before deriving the live heap end + emitter.instruction("add x14, x12, x14"); // x14 = current live heap end + emitter.instruction("cmp x10, x14"); // does the cached block point at or beyond the live heap end? + emitter.instruction("b.hs __rt_heap_alloc_small_bin_drop_tail"); // wild pointer: truncate the chain past the live heap window + // -- a parked cached block must be free: refcount 0 and no retained heap kind -- + emitter.instruction("ldr w15, [x10, #4]"); // load the cached block refcount from its header + emitter.instruction("cbnz w15, __rt_heap_alloc_small_bin_unlink_invalid"); // a live refcount marks a poisoned entry, unlink it + emitter.instruction("ldr x15, [x10, #8]"); // load the cached block heap kind from its header + emitter.instruction("cbnz x15, __rt_heap_alloc_small_bin_unlink_invalid"); // a retained live kind marks a poisoned entry, unlink it emitter.instruction("ldr w11, [x10]"); // load the cached block payload size before reusing it + emitter.instruction("cmp x11, #8"); // is the cached block large enough to carry allocator metadata? + emitter.instruction("b.lo __rt_heap_alloc_small_bin_unlink_invalid"); // unlink cached entries with impossible payload sizes + emitter.instruction("add x15, x10, #16"); // x15 = start of this cached block payload + emitter.instruction("add x15, x15, x11"); // x15 = cached block claimed end address + emitter.instruction("cmp x15, x14"); // does the cached block stay inside the live heap window? + emitter.instruction("b.hi __rt_heap_alloc_small_bin_unlink_invalid"); // unlink cached entries whose recorded size overruns the live heap emitter.instruction("cmp x11, x0"); // does the cached block satisfy the requested payload size? emitter.instruction("b.hs __rt_heap_alloc_small_bin_found"); // yes — reuse this cached block safely emitter.instruction("add x16, x10, #16"); // advance the previous next-pointer slot to current->next @@ -84,6 +110,14 @@ pub fn emit_heap_alloc(emitter: &mut Emitter) { emitter.instruction("add x9, x9, #8"); // move to the next bin-head slot emitter.instruction("b __rt_heap_alloc_small_bin_loop"); // keep searching the remaining small bins + emitter.label("__rt_heap_alloc_small_bin_drop_tail"); + emitter.instruction("str xzr, [x16]"); // drop the unreachable tail so no later scan follows the wild pointer + emitter.instruction("b __rt_heap_alloc_small_bin_next_class"); // this bin is exhausted from the truncation point, try the next class + emitter.label("__rt_heap_alloc_small_bin_unlink_invalid"); + emitter.instruction("ldr x15, [x10, #16]"); // the poisoned block is in-heap, so its next link is a safe load + emitter.instruction("str x15, [x16]"); // unlink the poisoned block from this size-class chain + emitter.instruction("b __rt_heap_alloc_small_bin_scan"); // reload the previous next slot and keep scanning this bin + emitter.label("__rt_heap_alloc_small_bin_found"); emitter.instruction("ldr x11, [x10, #16]"); // x11 = cached_small_block->next within this size class emitter.instruction("str x11, [x16]"); // unlink the cached block from its segregated small-bin chain @@ -284,7 +318,28 @@ fn emit_heap_alloc_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov r10, QWORD PTR [rcx]"); // r10 = current cached block header or null when this bin is exhausted emitter.instruction("test r10, r10"); // did this bin scan run out of cached blocks? emitter.instruction("jz __rt_heap_alloc_small_bin_next_class"); // try the next larger bin when this bin has no fitting block + // -- reject cached entries that escaped the live heap window before dereferencing them -- + crate::codegen_support::abi::emit_symbol_address(emitter, "rdx", "_heap_buf"); + emitter.instruction("cmp r10, rdx"); // does the cached block point below the heap buffer base? + emitter.instruction("jb __rt_heap_alloc_small_bin_drop_tail"); // wild pointer: truncate the chain, its next link cannot be trusted + crate::codegen_support::abi::emit_symbol_address(emitter, "rsi", "_heap_off"); + emitter.instruction("mov rsi, QWORD PTR [rsi]"); // load the current heap bump offset before deriving the live heap end + emitter.instruction("add rsi, rdx"); // rsi = current live heap end + emitter.instruction("cmp r10, rsi"); // does the cached block point at or beyond the live heap end? + emitter.instruction("jae __rt_heap_alloc_small_bin_drop_tail"); // wild pointer: truncate the chain past the live heap window + // -- a parked cached block must be free: refcount 0 and no retained heap kind -- + emitter.instruction("mov edx, DWORD PTR [r10 + 4]"); // load the cached block refcount from its header + emitter.instruction("test edx, edx"); // is the cached block still marked live rather than free? + emitter.instruction("jnz __rt_heap_alloc_small_bin_unlink_invalid"); // a live refcount marks a poisoned entry, unlink it + emitter.instruction("mov rdx, QWORD PTR [r10 + 8]"); // load the cached block heap kind from its header + emitter.instruction("test rdx, rdx"); // does the cached block retain a live heap kind? + emitter.instruction("jnz __rt_heap_alloc_small_bin_unlink_invalid"); // a retained live kind marks a poisoned entry, unlink it emitter.instruction("mov r11d, DWORD PTR [r10]"); // load the cached block payload size before reusing it + emitter.instruction("cmp r11, 8"); // is the cached block large enough to carry allocator metadata? + emitter.instruction("jb __rt_heap_alloc_small_bin_unlink_invalid"); // unlink cached entries with impossible payload sizes + emitter.instruction("lea rdx, [r10 + r11 + 16]"); // rdx = cached block claimed end address + emitter.instruction("cmp rdx, rsi"); // does the cached block stay inside the live heap window? + emitter.instruction("ja __rt_heap_alloc_small_bin_unlink_invalid"); // unlink cached entries whose recorded size overruns the live heap emitter.instruction("cmp r11, rax"); // does the cached block satisfy the requested payload size? emitter.instruction("jae __rt_heap_alloc_small_bin_found"); // yes — reuse this cached block safely emitter.instruction("lea rcx, [r10 + 16]"); // advance the previous next-pointer slot to current->next @@ -296,6 +351,14 @@ fn emit_heap_alloc_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("add r9, 8"); // move to the next small-bin head slot emitter.instruction("jmp __rt_heap_alloc_small_bin_loop"); // keep scanning the remaining small bins + emitter.label("__rt_heap_alloc_small_bin_drop_tail"); + emitter.instruction("mov QWORD PTR [rcx], 0"); // drop the unreachable tail so no later scan follows the wild pointer + emitter.instruction("jmp __rt_heap_alloc_small_bin_next_class"); // this bin is exhausted from the truncation point, try the next class + emitter.label("__rt_heap_alloc_small_bin_unlink_invalid"); + emitter.instruction("mov rdx, QWORD PTR [r10 + 16]"); // the poisoned block is in-heap, so its next link is a safe load + emitter.instruction("mov QWORD PTR [rcx], rdx"); // unlink the poisoned block from this size-class chain + emitter.instruction("jmp __rt_heap_alloc_small_bin_scan"); // reload the previous next slot and keep scanning this bin + emitter.label("__rt_heap_alloc_small_bin_found"); emitter.instruction("mov r11, QWORD PTR [r10 + 16]"); // load the cached block's next pointer within this size class emitter.instruction("mov QWORD PTR [rcx], r11"); // unlink the cached block from its segregated small-bin chain @@ -427,6 +490,6 @@ fn emit_heap_alloc_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 = write emitter.instruction("syscall"); // print the fatal heap exhaustion message to stderr emitter.instruction("mov edi, 1"); // exit code 1 for heap exhaustion - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall 60 = exit + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 = exit_group emitter.instruction("syscall"); // terminate the process after reporting heap exhaustion } diff --git a/src/codegen_support/runtime/arrays/heap_debug_fail.rs b/src/codegen_support/runtime/arrays/heap_debug_fail.rs index 188ae036b2..0a1ca3a419 100644 --- a/src/codegen_support/runtime/arrays/heap_debug_fail.rs +++ b/src/codegen_support/runtime/arrays/heap_debug_fail.rs @@ -27,7 +27,7 @@ pub fn emit_heap_debug_fail(emitter: &mut Emitter) { emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 = write emitter.instruction("syscall"); // print the heap-debug failure message to stderr emitter.instruction("mov edi, 1"); // exit code 1 marks the heap-debug process failure - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall 60 = exit + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 = exit_group emitter.instruction("syscall"); // terminate immediately after reporting the heap-debug failure return; } diff --git a/src/codegen_support/runtime/arrays/heap_debug_validate_free_list.rs b/src/codegen_support/runtime/arrays/heap_debug_validate_free_list.rs index 7b4a73e229..98c4dd7019 100644 --- a/src/codegen_support/runtime/arrays/heap_debug_validate_free_list.rs +++ b/src/codegen_support/runtime/arrays/heap_debug_validate_free_list.rs @@ -16,7 +16,8 @@ use crate::codegen_support::{emit::Emitter, platform::Arch}; /// assembly (x86_64 or ARM64). For each free block the helper checks: address lies /// within the live heap window, payload size meets the minimum (8 bytes), and blocks /// are strictly ascending with proper coalescing. For each small-bin chain it additionally -/// verifies the payload size falls within the size-class bounds. +/// verifies the payload size falls within the size-class bounds and that every cached block +/// is genuinely free (`refcount == 0` and no retained heap `kind`). /// /// # Arguments /// * `emitter` - Target-specific assembly emitter (mutated by emitting instructions). @@ -111,6 +112,12 @@ pub fn emit_heap_debug_validate_free_list(emitter: &mut Emitter) { emitter.instruction("lea rsi, [rdx + rsi + 16]"); // compute the end address of the cached block including its header emitter.instruction("cmp rsi, r10"); // does the cached block overrun the current heap end? emitter.instruction("ja __rt_heap_debug_validate_free_list_fail"); // cached blocks must remain fully inside the live heap window + emitter.instruction("mov esi, DWORD PTR [rdx + 4]"); // a cached block parked in a bin must have a cleared refcount + emitter.instruction("test esi, esi"); // is this cached block still marked live rather than free? + emitter.instruction("jnz __rt_heap_debug_validate_free_list_fail"); // a live refcount marks small-bin corruption + emitter.instruction("mov rsi, QWORD PTR [rdx + 8]"); // a cached block parked in a bin must not retain a live heap kind + emitter.instruction("test rsi, rsi"); // does this cached block retain a live heap kind? + emitter.instruction("jnz __rt_heap_debug_validate_free_list_fail"); // a retained live kind marks small-bin corruption emitter.instruction("mov rdx, QWORD PTR [rdx + 16]"); // advance to the next cached block in this size class emitter.instruction("jmp __rt_heap_debug_validate_small_bin_loop"); // continue validating this cached small-bin chain @@ -217,6 +224,10 @@ pub fn emit_heap_debug_validate_free_list(emitter: &mut Emitter) { emitter.instruction("add x17, x17, #16"); // x17 = end of the cached block including its 16-byte header emitter.instruction("cmp x17, x10"); // does the cached block run past the current heap end? emitter.instruction("b.hi __rt_heap_debug_validate_free_list_fail"); // cached blocks must remain fully inside the live heap window + emitter.instruction("ldr w17, [x14, #4]"); // a cached block parked in a bin must have a cleared refcount + emitter.instruction("cbnz w17, __rt_heap_debug_validate_free_list_fail"); // a live refcount marks small-bin corruption + emitter.instruction("ldr x17, [x14, #8]"); // a cached block parked in a bin must not retain a live heap kind + emitter.instruction("cbnz x17, __rt_heap_debug_validate_free_list_fail"); // a retained live kind marks small-bin corruption emitter.instruction("ldr x14, [x14, #16]"); // advance to the next cached block in this size class emitter.instruction("b __rt_heap_debug_validate_small_bin_loop"); // continue validating this cached small-bin chain diff --git a/src/codegen_support/runtime/arrays/heap_free.rs b/src/codegen_support/runtime/arrays/heap_free.rs index c14878f356..92ad280af0 100644 --- a/src/codegen_support/runtime/arrays/heap_free.rs +++ b/src/codegen_support/runtime/arrays/heap_free.rs @@ -136,7 +136,11 @@ pub fn emit_heap_free(emitter: &mut Emitter) { emitter.instruction("add x10, x10, x12"); // x10 = address of the chosen small-bin head slot crate::codegen_support::abi::emit_symbol_address(emitter, "x16", "_heap_debug_enabled"); emitter.instruction("ldr x16, [x16]"); // load the heap-debug enabled flag - emitter.instruction("cbz x16, __rt_heap_free_cache_small_insert"); // skip duplicate detection when heap-debug mode is disabled + emitter.instruction("cbnz x16, __rt_heap_free_cache_small_dupscan"); // heap-debug mode active, scan the bin for a double free + crate::codegen_support::abi::emit_symbol_address(emitter, "x16", "_web_heap_guard_enabled"); + emitter.instruction("ldr x16, [x16]"); // load the web heap-guard enabled flag + emitter.instruction("cbz x16, __rt_heap_free_cache_small_insert"); // neither guard active, skip duplicate detection + emitter.label("__rt_heap_free_cache_small_dupscan"); emitter.instruction("ldr x12, [x10]"); // x12 = current cached block while checking for duplicates emitter.label("__rt_heap_free_cache_small_scan"); emitter.instruction("cbz x12, __rt_heap_free_cache_small_insert"); // a null next pointer means the block is not already cached @@ -415,8 +419,13 @@ fn emit_heap_free_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("add r10, rcx"); // r10 = address of the selected small-bin head slot crate::codegen_support::abi::emit_symbol_address(emitter, "r8", "_heap_debug_enabled"); emitter.instruction("mov r8, QWORD PTR [r8]"); // reload the heap-debug enabled flag before checking cached-bin duplicates - emitter.instruction("test r8, r8"); // is duplicate detection enabled for the small-bin cache? - emitter.instruction("jz __rt_heap_free_cache_small_insert"); // skip duplicate detection when heap-debug mode is disabled + emitter.instruction("test r8, r8"); // is heap-debug duplicate detection enabled? + emitter.instruction("jnz __rt_heap_free_cache_small_dupscan"); // heap-debug mode active, scan the bin for a double free + crate::codegen_support::abi::emit_symbol_address(emitter, "r8", "_web_heap_guard_enabled"); + emitter.instruction("mov r8, QWORD PTR [r8]"); // reload the web heap-guard enabled flag + emitter.instruction("test r8, r8"); // is the web heap-guard double-free detection enabled? + emitter.instruction("jz __rt_heap_free_cache_small_insert"); // neither guard active, skip duplicate detection + emitter.label("__rt_heap_free_cache_small_dupscan"); emitter.instruction("mov rdx, QWORD PTR [r10]"); // start scanning the cached small-bin chain for duplicate headers emitter.label("__rt_heap_free_cache_small_scan"); emitter.instruction("test rdx, rdx"); // did the cached small-bin scan reach the tail? diff --git a/src/codegen_support/runtime/arrays/iterable_unsupported_kind.rs b/src/codegen_support/runtime/arrays/iterable_unsupported_kind.rs index ed0c295618..1dc272d9b6 100644 --- a/src/codegen_support/runtime/arrays/iterable_unsupported_kind.rs +++ b/src/codegen_support/runtime/arrays/iterable_unsupported_kind.rs @@ -62,6 +62,6 @@ fn emit_iterable_unsupported_kind_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 = write emitter.instruction("syscall"); // emit the iterable runtime fatal message before terminating emitter.instruction("mov edi, 70"); // use EX_SOFTWARE as the process exit status for consistency with the AArch64 path - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall 60 = exit + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 = exit_group emitter.instruction("syscall"); // terminate the process immediately after the iterable runtime fatal diagnostic } diff --git a/src/codegen_support/runtime/arrays/mixed_clone.rs b/src/codegen_support/runtime/arrays/mixed_clone.rs new file mode 100644 index 0000000000..97980ac72b --- /dev/null +++ b/src/codegen_support/runtime/arrays/mixed_clone.rs @@ -0,0 +1,70 @@ +//! Purpose: +//! Emits the resource-aware `__rt_mixed_clone` helper used by ordinary PHP +//! reads of boxed Mixed values. +//! +//! Called from: +//! - EIR `MixedClone` lowering and boxed array/hash read helpers. +//! +//! Key details: +//! - Ordinary values receive a detached Mixed cell through unbox/rebox. +//! - Resources retain and return the existing cell so all aliases share one +//! resource lifetime and the destructor runs only after the final owner drops. + +use crate::codegen_support::emit::Emitter; +use crate::codegen_support::platform::Arch; + +/// Emits `__rt_mixed_clone`, returning one owned PHP value read from a borrowed +/// Mixed cell in `x0`/`rax`. +pub fn emit_mixed_clone(emitter: &mut Emitter) { + if emitter.target.arch == Arch::X86_64 { + emit_mixed_clone_linux_x86_64(emitter); + return; + } + + emitter.blank(); + emitter.comment("--- runtime: mixed_clone ---"); + emitter.label_global("__rt_mixed_clone"); + emitter.instruction("sub sp, sp, #32"); // reserve the source cell and saved frame registers + emitter.instruction("stp x29, x30, [sp, #16]"); // preserve the caller frame and return address + emitter.instruction("add x29, sp, #16"); // establish a stable helper frame + emitter.instruction("str x0, [sp]"); // preserve the borrowed source cell across unboxing + emitter.instruction("bl __rt_mixed_unbox"); // expose the concrete tag and payload for value cloning + emitter.instruction("cmp x0, #9"); // does the value carry PHP resource identity? + emitter.instruction("b.ne __rt_mixed_clone_value"); // non-resources receive an independent zval cell + emitter.instruction("ldr x0, [sp]"); // reload the shared resource cell + emitter.instruction("bl __rt_incref"); // give the caller its own reference to the resource cell + emitter.instruction("ldr x0, [sp]"); // return the retained resource cell itself + emitter.instruction("b __rt_mixed_clone_done"); // skip detached value boxing for resources + emitter.label("__rt_mixed_clone_value"); + emitter.instruction("bl __rt_mixed_from_value"); // detach the ordinary value into a fresh owned cell + emitter.label("__rt_mixed_clone_done"); + emitter.instruction("ldp x29, x30, [sp, #16]"); // restore the caller frame and return address + emitter.instruction("add sp, sp, #32"); // release the helper frame + emitter.instruction("ret"); // return the owned Mixed value +} + +/// Emits the Linux x86_64 implementation of `__rt_mixed_clone` using the +/// runtime's custom Mixed register convention. +fn emit_mixed_clone_linux_x86_64(emitter: &mut Emitter) { + emitter.blank(); + emitter.comment("--- runtime: mixed_clone ---"); + emitter.label_global("__rt_mixed_clone"); + emitter.instruction("push rbp"); // preserve the caller frame pointer + emitter.instruction("mov rbp, rsp"); // establish a stable helper frame + emitter.instruction("sub rsp, 16"); // reserve one aligned source-cell spill slot + emitter.instruction("mov QWORD PTR [rbp - 8], rax"); // preserve the borrowed source cell across unboxing + emitter.instruction("call __rt_mixed_unbox"); // expose the concrete tag and payload for value cloning + emitter.instruction("cmp rax, 9"); // does the value carry PHP resource identity? + emitter.instruction("jne __rt_mixed_clone_value"); // non-resources receive an independent zval cell + emitter.instruction("mov rax, QWORD PTR [rbp - 8]"); // reload the shared resource cell + emitter.instruction("call __rt_incref"); // give the caller its own reference to the resource cell + emitter.instruction("mov rax, QWORD PTR [rbp - 8]"); // return the retained resource cell itself + emitter.instruction("jmp __rt_mixed_clone_done"); // skip detached value boxing for resources + emitter.label("__rt_mixed_clone_value"); + emitter.instruction("mov rsi, rdx"); // adapt the unboxed high word to mixed_from_value's ABI + emitter.instruction("call __rt_mixed_from_value"); // detach the ordinary value into a fresh owned cell + emitter.label("__rt_mixed_clone_done"); + emitter.instruction("mov rsp, rbp"); // release the source-cell spill slot + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return the owned Mixed value +} diff --git a/src/codegen_support/runtime/arrays/mixed_strict_eq.rs b/src/codegen_support/runtime/arrays/mixed_strict_eq.rs index 8517b8cc4f..0d30c05035 100644 --- a/src/codegen_support/runtime/arrays/mixed_strict_eq.rs +++ b/src/codegen_support/runtime/arrays/mixed_strict_eq.rs @@ -43,10 +43,23 @@ pub fn emit_mixed_strict_eq(emitter: &mut Emitter) { emitter.instruction("ldr x0, [sp, #8]"); // reload the right mixed pointer into the helper argument register emitter.instruction("bl __rt_mixed_unbox"); // right mixed pointer -> x0=tag, x1=value_lo, x2=value_hi emitter.instruction("ldr x9, [sp, #16]"); // reload the saved left runtime tag - emitter.instruction("cmp x9, x0"); // strict equality first requires matching runtime tags - emitter.instruction("b.ne __rt_mixed_strict_eq_false"); // different payload tags are never strictly equal + + // -- array/hash payloads (tags 4 and 5) compare by deep structure, not pointer identity, and + // a packed indexed array (tag 4) may be structurally equal to a hash (tag 5) -- + emitter.instruction("sub x10, x9, #4"); // is the left tag an array-ish tag (4 or 5)? + emitter.instruction("cmp x10, #1"); // fold tags 4 and 5 into the range 0..1 + emitter.instruction("b.hi __rt_mixed_strict_eq_tag_gate"); // non-array left payloads take the strict-tag path + emitter.instruction("sub x11, x0, #4"); // is the right tag also array-ish (4 or 5)? + emitter.instruction("cmp x11, #1"); // fold the right tag into the range 0..1 + emitter.instruction("b.hi __rt_mixed_strict_eq_false"); // an array is never strictly equal to a non-array + emitter.instruction("ldr x0, [sp, #24]"); // left array/hash pointer (x1 already holds the right one) + emitter.instruction("bl __rt_array_strict_eq"); // deep structural comparison of the two arrays + emitter.instruction("b __rt_mixed_strict_eq_done"); // return the structural-equality result // -- dispatch on the shared concrete runtime tag -- + emitter.label("__rt_mixed_strict_eq_tag_gate"); + emitter.instruction("cmp x9, x0"); // strict equality first requires matching runtime tags + emitter.instruction("b.ne __rt_mixed_strict_eq_false"); // different payload tags are never strictly equal emitter.instruction("cmp x0, #8"); // do both payloads represent PHP null? emitter.instruction("b.eq __rt_mixed_strict_eq_true"); // null identity depends only on the matching runtime tag emitter.instruction("cmp x0, #1"); // do both payloads hold strings? @@ -90,6 +103,7 @@ fn emit_mixed_strict_eq_linux_x86_64(emitter: &mut Emitter) { emitter.comment("--- runtime: mixed_strict_eq ---"); emitter.label_global("__rt_mixed_strict_eq"); + emitter.instruction("push rbp"); // preserve rbp and realign rsp so every helper call is 16-byte aligned emitter.instruction("sub rsp, 64"); // allocate stack space for both operands, payloads, and the saved comparison state emitter.instruction("mov QWORD PTR [rsp], rdi"); // save the incoming left mixed pointer for the later comparison and cleanup path emitter.instruction("mov QWORD PTR [rsp + 8], rsi"); // save the incoming right mixed pointer for the later comparison and cleanup path @@ -103,9 +117,25 @@ fn emit_mixed_strict_eq_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov rax, QWORD PTR [rsp + 8]"); // reload the right mixed pointer into the x86_64 mixed-unbox input register abi::emit_call_label(emitter, "__rt_mixed_unbox"); // right mixed pointer -> rax=tag, rdi=value_lo, rdx=value_hi emitter.instruction("mov r10, QWORD PTR [rsp + 16]"); // reload the saved left runtime tag + + // -- array/hash payloads (tags 4 and 5) compare by deep structure, not pointer identity, and + // a packed indexed array (tag 4) may be structurally equal to a hash (tag 5) -- + emitter.instruction("mov r11, r10"); // copy the left tag to test the array-ish range + emitter.instruction("sub r11, 4"); // is the left tag an array-ish tag (4 or 5)? + emitter.instruction("cmp r11, 1"); // fold tags 4 and 5 into the range 0..1 + emitter.instruction("ja __rt_mixed_strict_eq_tag_gate"); // non-array left payloads take the strict-tag path + emitter.instruction("mov r11, rax"); // copy the right tag to test the array-ish range + emitter.instruction("sub r11, 4"); // is the right tag also array-ish (4 or 5)? + emitter.instruction("cmp r11, 1"); // fold the right tag into the range 0..1 + emitter.instruction("ja __rt_mixed_strict_eq_false"); // an array is never strictly equal to a non-array + emitter.instruction("mov rsi, rdi"); // right array/hash pointer into the second argument + emitter.instruction("mov rdi, QWORD PTR [rsp + 24]"); // left array/hash pointer into the first argument + emitter.instruction("call __rt_array_strict_eq"); // deep structural comparison of the two arrays + emitter.instruction("jmp __rt_mixed_strict_eq_done"); // return the structural-equality result + + emitter.label("__rt_mixed_strict_eq_tag_gate"); emitter.instruction("cmp r10, rax"); // strict equality first requires matching runtime tags emitter.instruction("jne __rt_mixed_strict_eq_false"); // different payload tags are never strictly equal - emitter.instruction("cmp rax, 8"); // do both payloads represent PHP null? emitter.instruction("je __rt_mixed_strict_eq_true"); // null identity depends only on the matching runtime tag emitter.instruction("cmp rax, 1"); // do both payloads hold strings? @@ -132,5 +162,6 @@ fn emit_mixed_strict_eq_linux_x86_64(emitter: &mut Emitter) { emitter.label("__rt_mixed_strict_eq_done"); emitter.instruction("add rsp, 64"); // release the helper stack frame + emitter.instruction("pop rbp"); // restore the caller frame pointer saved for stack alignment emitter.instruction("ret"); // return the strict-equality boolean in rax } diff --git a/src/codegen_support/runtime/arrays/mod.rs b/src/codegen_support/runtime/arrays/mod.rs index 098fb79804..eb114ef664 100644 --- a/src/codegen_support/runtime/arrays/mod.rs +++ b/src/codegen_support/runtime/arrays/mod.rs @@ -42,6 +42,7 @@ mod array_intersect_refcounted; mod array_intersect_key; mod array_is_list; mod array_key_exists; +mod array_key_exists_mixed_key; mod array_map; mod array_map_mixed; mod array_map_str; @@ -77,6 +78,7 @@ mod array_slice; mod array_slice_refcounted; mod array_splice; mod array_splice_refcounted; +mod array_strict_eq; mod array_sum; mod array_sum_mixed; mod array_to_hash; @@ -141,6 +143,7 @@ mod nan_bool_coercion_warning; mod iterable_unsupported_kind; mod iterable_write_stdout; mod mixed_abs; +mod mixed_clone; mod mixed_from_value; mod mixed_instanceof; mod mixed_cast_bool; @@ -231,6 +234,8 @@ pub use array_intersect_key::emit_array_intersect_key; pub use array_is_list::emit_array_is_list; /// Emit array key existence check helper. pub use array_key_exists::emit_array_key_exists; +/// Emit the storage-kind-dispatching presence-only array key existence helper. +pub use array_key_exists_mixed_key::emit_array_key_exists_mixed_key; /// Emit array map helper. pub use array_map::emit_array_map; /// Emit mixed-result array map helper. @@ -301,6 +306,8 @@ pub use array_slice_refcounted::emit_array_slice_refcounted; pub use array_splice::emit_array_splice; /// Emit array splice helper. pub use array_splice_refcounted::emit_array_splice_refcounted; +/// Emit deep array strict-equality (`===`) helper. +pub use array_strict_eq::emit_array_strict_eq; /// Emit refcounted array splice helper. pub use array_sum::emit_array_sum; /// Emit array sum helper. @@ -417,6 +424,8 @@ pub use ksort::emit_ksort; pub use natsort::emit_natsort; /// Emit natural sort helper. pub use mixed_abs::emit_mixed_abs; +/// Emit a resource-aware owned Mixed value read. +pub use mixed_clone::emit_mixed_clone; pub use mixed_from_value::emit_mixed_from_value; /// Emit Mixed from value conversion helper. pub use mixed_instanceof::emit_mixed_instanceof; diff --git a/src/codegen_support/runtime/arrays/object_free_deep.rs b/src/codegen_support/runtime/arrays/object_free_deep.rs index 234886259f..2d4d1546e8 100644 --- a/src/codegen_support/runtime/arrays/object_free_deep.rs +++ b/src/codegen_support/runtime/arrays/object_free_deep.rs @@ -172,18 +172,20 @@ pub fn emit_object_free_deep(emitter: &mut Emitter) { emitter.instruction("b __rt_object_free_deep_no_dyn_props"); // free custom fixed-array storage without generic descriptor walking emitter.label("__rt_object_free_deep_not_spl_fixed"); - // -- derive property count from the object payload size -- - emitter.instruction("ldr w9, [x0, #-16]"); // load the object payload size from the heap header - emitter.instruction("sub x9, x9, #8"); // subtract the leading class_id field - emitter.instruction("lsr x9, x9, #4"); // divide by 16 to get the number of property slots - emitter.instruction("str x9, [sp, #16]"); // save the property count for the cleanup loop - - // -- resolve the per-class property tag descriptor -- + // -- resolve the per-class layout and property tag descriptor -- emitter.instruction("ldr x10, [x0]"); // load the runtime class_id from the object payload crate::codegen_support::abi::emit_symbol_address(emitter, "x11", "_class_gc_desc_count"); emitter.instruction("ldr x11, [x11]"); // load the number of emitted class descriptors emitter.instruction("cmp x10, x11"); // is class_id within the descriptor table? - emitter.instruction("b.hs __rt_object_free_deep_struct"); // invalid class ids fall back to a shallow free + emitter.instruction("b.hs __rt_object_free_deep_no_dyn_props"); // invalid class ids fall back to a shallow free without table indexing + crate::codegen_support::abi::emit_symbol_address(emitter, "x11", "_class_object_payload_sizes"); + emitter.instruction("ldr x9, [x11, x10, lsl #3]"); // load the class-declared payload size instead of reused block capacity + crate::codegen_support::abi::emit_symbol_address(emitter, "x11", "_class_object_dynamic_prop_flags"); + emitter.instruction("ldr x11, [x11, x10, lsl #3]"); // load whether this class reserves a dynamic-property tail + emitter.instruction("sub x9, x9, #8"); // subtract the leading class_id field from the declared layout + emitter.instruction("sub x9, x9, x11, lsl #3"); // exclude the optional eight-byte dynamic-property tail + emitter.instruction("lsr x9, x9, #4"); // divide the fixed property region by 16 bytes per slot + emitter.instruction("str x9, [sp, #16]"); // save the authoritative property count for the cleanup loop crate::codegen_support::abi::emit_symbol_address(emitter, "x11", "_class_gc_desc_ptrs"); emitter.instruction("lsl x12, x10, #3"); // scale class_id by 8 bytes per descriptor pointer emitter.instruction("ldr x11, [x11, x12]"); // load the tag descriptor pointer for this class @@ -240,18 +242,16 @@ pub fn emit_object_free_deep(emitter: &mut Emitter) { emitter.label("__rt_object_free_deep_struct"); // -- if the object carries a #[\AllowDynamicProperties] hashtable, free it -- - // The presence of the dyn_props slot is encoded in the payload size: the - // base layout is `8 + num_props * 16` (always a multiple of 16 plus 8 for - // the class_id field), so an extra 8-byte tail signals an ADP slot at - // offset `size - 16` from the object payload start. + // Reused whole heap blocks can exceed the class layout, so the class tables + // are the only authoritative source for the tail's presence and offset. emitter.instruction("ldr x0, [sp, #0]"); // reload the object pointer for the dyn_props check - emitter.instruction("ldr w9, [x0, #-16]"); // load the object payload size from the heap header - emitter.instruction("sub x9, x9, #8"); // subtract the leading class_id field - emitter.instruction("and x10, x9, #15"); // isolate the low 4 bits of the property region size - emitter.instruction("cmp x10, #8"); // 8 leftover bytes signal a dyn_props pointer slot - emitter.instruction("b.ne __rt_object_free_deep_no_dyn_props"); // no dyn_props tail → skip hashtable cleanup - emitter.instruction("sub x9, x9, #8"); // back out the dyn_props slot from the property region size - emitter.instruction("add x9, x9, #8"); // re-add the leading class_id offset to land on the dyn_props slot + emitter.instruction("ldr x10, [x0]"); // reload the runtime class_id for the layout tables + crate::codegen_support::abi::emit_symbol_address(emitter, "x11", "_class_object_dynamic_prop_flags"); + emitter.instruction("ldr x11, [x11, x10, lsl #3]"); // load the class-declared dynamic-property-tail flag + emitter.instruction("cbz x11, __rt_object_free_deep_no_dyn_props"); // classes without the tail own no dynamic-property hashtable + crate::codegen_support::abi::emit_symbol_address(emitter, "x11", "_class_object_payload_sizes"); + emitter.instruction("ldr x9, [x11, x10, lsl #3]"); // load the exact class-declared object payload size + emitter.instruction("sub x9, x9, #8"); // the dynamic-property pointer occupies the final eight bytes emitter.instruction("ldr x11, [x0, x9]"); // load the dyn_props hashtable pointer from the slot emitter.instruction("cbz x11, __rt_object_free_deep_no_dyn_props"); // null hashtables (lazy init never happened) need no cleanup emitter.instruction("mov x0, x11"); // pass the hashtable pointer to the uniform decref helper @@ -407,13 +407,18 @@ fn emit_object_free_deep_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("jmp __rt_object_free_deep_no_dyn_props"); // free custom fixed-array storage without generic descriptor walking emitter.label("__rt_object_free_deep_not_spl_fixed"); - emitter.instruction("mov r10d, DWORD PTR [rax - 16]"); // load the object payload size from the uniform heap header - emitter.instruction("sub r10, 8"); // subtract the leading class_id field from the payload size to isolate property storage - emitter.instruction("shr r10, 4"); // divide by 16 because every property slot occupies two qwords - emitter.instruction("mov QWORD PTR [rbp - 24], r10"); // save the total property count for the deep-free loop emitter.instruction("mov r10, QWORD PTR [rax]"); // load the runtime class id from the object payload abi::emit_cmp_reg_to_symbol(emitter, "r10", "_class_gc_desc_count"); // is the runtime class id within the emitted descriptor table? - emitter.instruction("jae __rt_object_free_deep_struct"); // invalid class ids fall back to a shallow object free on x86_64 + emitter.instruction("jae __rt_object_free_deep_no_dyn_props"); // invalid class ids fall back to a shallow free without table indexing + abi::emit_symbol_address(emitter, "r11", "_class_object_payload_sizes"); // materialize the class-declared payload-size table + emitter.instruction("mov rcx, QWORD PTR [r11 + r10 * 8]"); // load the declared layout size instead of reused block capacity + abi::emit_symbol_address(emitter, "r11", "_class_object_dynamic_prop_flags"); // materialize the dynamic-property-tail flag table + emitter.instruction("mov r8, QWORD PTR [r11 + r10 * 8]"); // load whether this class reserves a dynamic-property tail + emitter.instruction("sub rcx, 8"); // subtract the leading class_id field from the declared layout + emitter.instruction("shl r8, 3"); // convert the boolean tail flag into its eight-byte size + emitter.instruction("sub rcx, r8"); // exclude the optional tail from the fixed property region + emitter.instruction("shr rcx, 4"); // divide the fixed property region by 16 bytes per slot + emitter.instruction("mov QWORD PTR [rbp - 24], rcx"); // save the authoritative property count for the deep-free loop abi::emit_symbol_address(emitter, "r11", "_class_gc_desc_ptrs"); // materialize the base address of the class property-tag descriptor table emitter.instruction("mov r11, QWORD PTR [r11 + r10 * 8]"); // load the property-tag descriptor pointer for this object class emitter.instruction("mov QWORD PTR [rbp - 16], r11"); // save the descriptor pointer for the object-property cleanup loop @@ -459,14 +464,14 @@ fn emit_object_free_deep_linux_x86_64(emitter: &mut Emitter) { // -- if the object carries a #[\AllowDynamicProperties] hashtable, free it -- emitter.instruction("mov rax, QWORD PTR [rbp - 8]"); // reload the object pointer for the dyn_props check - emitter.instruction("mov r10d, DWORD PTR [rax - 16]"); // load the object payload size from the heap header - emitter.instruction("sub r10, 8"); // subtract the leading class_id field - emitter.instruction("mov r11, r10"); // copy the property region size before isolating the low nibble - emitter.instruction("and r11, 15"); // isolate the low 4 bits of the property region size - emitter.instruction("cmp r11, 8"); // 8 leftover bytes signal a dyn_props pointer slot - emitter.instruction("jne __rt_object_free_deep_no_dyn_props"); // no dyn_props tail → skip hashtable cleanup - emitter.instruction("sub r10, 8"); // back out the dyn_props slot from the property region size - emitter.instruction("add r10, 8"); // re-add the leading class_id offset to land on the dyn_props slot + emitter.instruction("mov r10, QWORD PTR [rax]"); // reload the runtime class id for the authoritative layout tables + abi::emit_symbol_address(emitter, "r11", "_class_object_dynamic_prop_flags"); // materialize the dynamic-property-tail flag table + emitter.instruction("mov r11, QWORD PTR [r11 + r10 * 8]"); // load whether this class owns a dynamic-property tail + emitter.instruction("test r11, r11"); // does this exact class layout reserve the tail slot? + emitter.instruction("jz __rt_object_free_deep_no_dyn_props"); // classes without the tail own no dynamic-property hashtable + abi::emit_symbol_address(emitter, "r11", "_class_object_payload_sizes"); // materialize the exact class payload-size table + emitter.instruction("mov r10, QWORD PTR [r11 + r10 * 8]"); // load the exact class-declared payload size + emitter.instruction("sub r10, 8"); // the dynamic-property pointer occupies the final eight bytes emitter.instruction("mov r11, QWORD PTR [rax + r10]"); // load the dyn_props hashtable pointer from the slot emitter.instruction("test r11, r11"); // null hashtables (lazy init never happened) need no cleanup emitter.instruction("jz __rt_object_free_deep_no_dyn_props"); // skip cleanup for null dyn_props slot diff --git a/src/codegen_support/runtime/buffers/bounds_fail.rs b/src/codegen_support/runtime/buffers/bounds_fail.rs index aa16730b40..4d09022252 100644 --- a/src/codegen_support/runtime/buffers/bounds_fail.rs +++ b/src/codegen_support/runtime/buffers/bounds_fail.rs @@ -33,7 +33,7 @@ pub fn emit_buffer_bounds_fail(emitter: &mut Emitter) { } /// Emits the Linux x86_64 variant of `__rt_buffer_bounds_fail`. -/// Uses syscall 1 (write) to emit the error message to stderr, then syscall 60 (exit) +/// Uses syscall 1 (write) to emit the error message to stderr, then syscall 231 (`exit_group`) /// to terminate with exit code 70. fn emit_buffer_bounds_fail_linux_x86_64(emitter: &mut Emitter) { emitter.blank(); @@ -45,6 +45,6 @@ fn emit_buffer_bounds_fail_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 = write emitter.instruction("syscall"); // emit the fatal buffer-bounds diagnostic to stderr emitter.instruction("mov edi, 70"); // use EX_SOFTWARE as the process exit status for consistency with the ARM runtime - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall 60 = exit + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 = exit_group emitter.instruction("syscall"); // terminate the process immediately after the fatal buffer-bounds diagnostic } diff --git a/src/codegen_support/runtime/buffers/use_after_free.rs b/src/codegen_support/runtime/buffers/use_after_free.rs index a634f30564..665c3a9492 100644 --- a/src/codegen_support/runtime/buffers/use_after_free.rs +++ b/src/codegen_support/runtime/buffers/use_after_free.rs @@ -34,7 +34,7 @@ pub fn emit_buffer_use_after_free(emitter: &mut Emitter) { /// Emits the Linux x86_64 variant of `__rt_buffer_use_after_free`. /// Uses Linux syscall 1 (`write`) to emit the error message to stderr (fd=2), then -/// syscall 60 (`exit`) to terminate with exit code 70 (EX_SOFTWARE), matching the ARM64 runtime. +/// syscall 231 (`exit_group`) to terminate with exit code 70 (EX_SOFTWARE), matching ARM64. fn emit_buffer_use_after_free_linux_x86_64(emitter: &mut Emitter) { emitter.blank(); emitter.comment("--- runtime: buffer_use_after_free ---"); @@ -45,6 +45,6 @@ fn emit_buffer_use_after_free_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 = write emitter.instruction("syscall"); // emit the fatal buffer use-after-free diagnostic to stderr emitter.instruction("mov edi, 70"); // use EX_SOFTWARE as the process exit status for consistency with the ARM runtime - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall 60 = exit + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 = exit_group emitter.instruction("syscall"); // terminate the process immediately after the fatal buffer use-after-free diagnostic } diff --git a/src/codegen_support/runtime/callables/is_callable.rs b/src/codegen_support/runtime/callables/is_callable.rs index ee5fddb011..4432c5f46c 100644 --- a/src/codegen_support/runtime/callables/is_callable.rs +++ b/src/codegen_support/runtime/callables/is_callable.rs @@ -519,7 +519,8 @@ fn emit_assoc_aarch64(emitter: &mut Emitter) { /// Emits the ARM64 runtime helper for boxed Mixed callable dispatch. /// Unboxes the Mixed payload and dispatches by runtime tag: /// string → `__rt_is_callable_string`, array → `__rt_is_callable_array`, -/// assoc → `__rt_is_callable_assoc`, object → `__rt_is_callable_object`. +/// assoc → `__rt_is_callable_assoc`, object → `__rt_is_callable_object`, +/// callable descriptor → true. /// Input: x0=Mixed pointer. Output: x0=1 (true) or 0 (false). fn emit_mixed_aarch64(emitter: &mut Emitter) { emitter.blank(); @@ -538,6 +539,8 @@ fn emit_mixed_aarch64(emitter: &mut Emitter) { emitter.instruction("b.eq __rt_is_callable_mixed_assoc"); // associative arrays may carry numeric 0/1 callable keys emitter.instruction("cmp x0, #6"); // is the mixed payload an object? emitter.instruction("b.eq __rt_is_callable_mixed_object"); // objects may be invokable through public __invoke + emitter.instruction("cmp x0, #10"); // is the mixed payload a callable descriptor? + emitter.instruction("b.eq __rt_is_callable_mixed_true"); // boxed closure and first-class descriptors are callable emitter.instruction("mov x0, #0"); // unsupported mixed payloads are not callable emitter.instruction("b __rt_is_callable_mixed_done"); // restore frame before returning false @@ -560,6 +563,10 @@ fn emit_mixed_aarch64(emitter: &mut Emitter) { emitter.label("__rt_is_callable_mixed_object"); emitter.instruction("mov x0, x1"); // pass unboxed object pointer to invokable-object lookup abi::emit_call_label(emitter, "__rt_is_callable_object"); // test for public __invoke + emitter.instruction("b __rt_is_callable_mixed_done"); // restore frame after invokable-object lookup + + emitter.label("__rt_is_callable_mixed_true"); + emitter.instruction("mov x0, #1"); // a boxed callable descriptor is callable by construction emitter.label("__rt_is_callable_mixed_done"); emitter.instruction("ldp x29, x30, [sp, #16]"); // restore caller frame pointer and return address @@ -1118,7 +1125,8 @@ fn emit_assoc_x86_64(emitter: &mut Emitter) { /// Emits the x86_64 runtime helper for boxed Mixed callable dispatch. /// Unboxes the Mixed payload and dispatches by runtime tag: /// string → `__rt_is_callable_string`, array → `__rt_is_callable_array`, -/// assoc → `__rt_is_callable_assoc`, object → `__rt_is_callable_object`. +/// assoc → `__rt_is_callable_assoc`, object → `__rt_is_callable_object`, +/// callable descriptor → true. /// Input: rdi=Mixed pointer. Output: rax=1 (true) or 0 (false). fn emit_mixed_x86_64(emitter: &mut Emitter) { emitter.blank(); @@ -1137,6 +1145,8 @@ fn emit_mixed_x86_64(emitter: &mut Emitter) { emitter.instruction("je __rt_is_callable_mixed_assoc_x86_64"); // associative arrays may be method callables emitter.instruction("cmp rax, 6"); // is the mixed payload an object? emitter.instruction("je __rt_is_callable_mixed_object_x86_64"); // objects may be invokable through public __invoke + emitter.instruction("cmp rax, 10"); // is the mixed payload a callable descriptor? + emitter.instruction("je __rt_is_callable_mixed_true_x86_64"); // boxed closure and first-class descriptors are callable emitter.instruction("xor eax, eax"); // unsupported mixed payloads are not callable emitter.instruction("jmp __rt_is_callable_mixed_done_x86_64"); // restore frame before returning false @@ -1155,6 +1165,10 @@ fn emit_mixed_x86_64(emitter: &mut Emitter) { emitter.label("__rt_is_callable_mixed_object_x86_64"); abi::emit_call_label(emitter, "__rt_is_callable_object"); // rdi already holds unboxed object pointer + emitter.instruction("jmp __rt_is_callable_mixed_done_x86_64"); // restore frame after invokable-object lookup + + emitter.label("__rt_is_callable_mixed_true_x86_64"); + emitter.instruction("mov eax, 1"); // a boxed callable descriptor is callable by construction emitter.label("__rt_is_callable_mixed_done_x86_64"); emitter.instruction("pop rbp"); // restore caller frame pointer diff --git a/src/codegen_support/runtime/data/fixed.rs b/src/codegen_support/runtime/data/fixed.rs index 52f61fffbc..1fad28ce0a 100644 --- a/src/codegen_support/runtime/data/fixed.rs +++ b/src/codegen_support/runtime/data/fixed.rs @@ -213,6 +213,7 @@ pub(crate) fn emit_runtime_data_fixed(heap_size: usize, target: Target) -> Strin out.push_str(".comm _heap_free_list, 8, 3\n"); out.push_str(".comm _heap_small_bins, 32, 3\n"); out.push_str(".comm _heap_debug_enabled, 8, 3\n"); + out.push_str(".comm _web_heap_guard_enabled, 8, 3\n"); // PHP object-handle pool. `_obj_handle_index` is a DIRECT-MAPPED side table // holding one u32 handle per 16-byte granule of `_heap_buf`: two live heap // blocks can never share a granule because the smallest block is 16 header diff --git a/src/codegen_support/runtime/data/user.rs b/src/codegen_support/runtime/data/user.rs index 8b387795a9..94f7f55a9e 100644 --- a/src/codegen_support/runtime/data/user.rs +++ b/src/codegen_support/runtime/data/user.rs @@ -2655,9 +2655,10 @@ mod tests { is_readonly_class: false, allow_dynamic_properties: false, constants: HashMap::new(), - constant_types: HashMap::new(), - constant_visibilities: HashMap::new(), - final_constants: HashSet::new(), + constant_deprecations: HashMap::new(), + constant_types: HashMap::new(), + constant_visibilities: HashMap::new(), + final_constants: HashSet::new(), attribute_names: Vec::new(), attribute_args: Vec::new(), method_attribute_names: HashMap::new(), diff --git a/src/codegen_support/runtime/emitters/managed.rs b/src/codegen_support/runtime/emitters/managed.rs index 8d74938d13..da1ccdc1b0 100644 --- a/src/codegen_support/runtime/emitters/managed.rs +++ b/src/codegen_support/runtime/emitters/managed.rs @@ -66,6 +66,7 @@ pub(super) fn emit_managed_runtime(emitter: &mut Emitter, features: RuntimeFeatu arrays::emit_hash_count(emitter); arrays::emit_hash_free_deep(emitter); arrays::emit_array_key_exists(emitter); + arrays::emit_array_key_exists_mixed_key(emitter); arrays::emit_undefined_array_key_warning(emitter); arrays::emit_array_search(emitter); arrays::emit_in_array_mixed_int(emitter); @@ -145,6 +146,7 @@ pub(super) fn emit_managed_runtime(emitter: &mut Emitter, features: RuntimeFeatu arrays::emit_gc_note_child_ref(emitter); arrays::emit_gc_mark_reachable(emitter); arrays::emit_gc_collect_cycles(emitter); + arrays::emit_mixed_clone(emitter); arrays::emit_mixed_from_value(emitter); arrays::emit_mixed_abs(emitter); arrays::emit_mixed_instanceof(emitter); @@ -162,6 +164,7 @@ pub(super) fn emit_managed_runtime(emitter: &mut Emitter, features: RuntimeFeatu arrays::emit_mixed_numeric_binops(emitter); arrays::emit_int_checked_binops(emitter); arrays::emit_mixed_strict_eq(emitter); + arrays::emit_array_strict_eq(emitter); arrays::emit_mixed_unbox(emitter); arrays::emit_mixed_write_stdout(emitter); arrays::emit_object_free_deep(emitter); diff --git a/src/codegen_support/runtime/emitters/platform.rs b/src/codegen_support/runtime/emitters/platform.rs index df7de2e46a..6afc0b4a92 100644 --- a/src/codegen_support/runtime/emitters/platform.rs +++ b/src/codegen_support/runtime/emitters/platform.rs @@ -7,7 +7,7 @@ //! Key details: //! - Preserves the dependency order among stream, output-buffering, pointer, zval, and fiber helpers. -use super::super::{fibers, io, pointers, zval}; +use super::super::{fibers, io, pdo, pointers, zval}; use crate::codegen_support::emit::Emitter; use crate::codegen_support::RuntimeFeatures; @@ -233,6 +233,15 @@ pub(super) fn emit_platform_runtime(emitter: &mut Emitter, features: RuntimeFeat zval::emit_zval_free_array(emitter); zval::emit_zval_free(emitter); + // PDO Tier-D callback adapters. Emitted only when a PDO callback registration is reachable; + // placed after arrays/heap/mixed and zval so adapters can call their shared helpers. + if features.pdo_udf { + pdo::emit_pdo_call_collation(emitter); + pdo::emit_pdo_call_scalar(emitter); + pdo::emit_pdo_call_agg_step(emitter); + pdo::emit_pdo_call_agg_final(emitter); + } + // Fiber runtime functions (cooperative coroutines) fibers::emit_fiber_alloc_stack(emitter); fibers::emit_fiber_free_stack(emitter); diff --git a/src/codegen_support/runtime/emitters/tests.rs b/src/codegen_support/runtime/emitters/tests.rs index d74c9b78df..6ef169922f 100644 --- a/src/codegen_support/runtime/emitters/tests.rs +++ b/src/codegen_support/runtime/emitters/tests.rs @@ -94,6 +94,47 @@ fn test_linux_x86_64_runtime_uses_shared_surface() { } } +/// Verifies PDO Tier-D callback adapters are emitted under `pdo_udf` on both targets. +#[test] +fn test_runtime_emits_pdo_call_collation_when_pdo_udf() { + for (platform, arch) in [ + (Platform::MacOS, Arch::AArch64), + (Platform::Linux, Arch::X86_64), + ] { + let mut emitter = Emitter::new(Target::new(platform, arch)); + emit_runtime(&mut emitter, RuntimeFeatures::all()); + let asm = emitter.output(); + for sym in [ + "__rt_pdo_call_collation", + "__rt_pdo_call_scalar", + "__rt_pdo_call_agg_step", + "__rt_pdo_call_agg_final", + ] { + assert!( + asm.contains(&format!(".globl {}\n", sym)), + "pdo_udf runtime missing {} for {:?}/{:?}", + sym, + platform, + arch + ); + } + } +} + +/// Verifies PDO Tier-D adapters are omitted when `pdo_udf` is not requested. +#[test] +fn test_runtime_omits_pdo_call_collation_without_pdo_udf() { + let mut emitter = Emitter::new(Target::new(Platform::MacOS, Arch::AArch64)); + emit_runtime(&mut emitter, RuntimeFeatures::none()); + let asm = emitter.output(); + assert!(!asm.contains("__rt_pdo_call_collation:")); + assert!(!asm.contains(".globl __rt_pdo_call_collation\n")); + assert!(!asm.contains("__rt_pdo_call_scalar:")); + assert!(!asm.contains(".globl __rt_pdo_call_scalar\n")); + assert!(!asm.contains(".globl __rt_pdo_call_agg_step\n")); + assert!(!asm.contains(".globl __rt_pdo_call_agg_final\n")); +} + /// Verifies the full macOS AArch64 runtime still assembles once per-symbol /// dead stripping is enabled. The real codegen path renames internal labels /// to `L`-locals and appends a `.subsections_via_symbols` footer; under that diff --git a/src/codegen_support/runtime/exceptions/dynamic_instanceof.rs b/src/codegen_support/runtime/exceptions/dynamic_instanceof.rs index 72d571afac..2a3b7d74d4 100644 --- a/src/codegen_support/runtime/exceptions/dynamic_instanceof.rs +++ b/src/codegen_support/runtime/exceptions/dynamic_instanceof.rs @@ -180,6 +180,6 @@ fn emit_dynamic_instanceof_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov eax, 1"); // Linux syscall 1 = write emitter.instruction("syscall"); // emit the dynamic instanceof TypeError diagnostic emitter.instruction("mov edi, 1"); // exit status 1 indicates abnormal termination - emitter.instruction("mov eax, 60"); // Linux syscall 60 = exit + emitter.instruction("mov eax, 231"); // Linux syscall 231 = exit_group emitter.instruction("syscall"); // terminate after the dynamic instanceof TypeError } diff --git a/src/codegen_support/runtime/exceptions/matches.rs b/src/codegen_support/runtime/exceptions/matches.rs index 2c6c269d4b..1017541d46 100644 --- a/src/codegen_support/runtime/exceptions/matches.rs +++ b/src/codegen_support/runtime/exceptions/matches.rs @@ -94,7 +94,8 @@ pub fn emit_exception_matches(emitter: &mut Emitter) { /// Output: eax = 1 if the thrown object matches the catch target, 0 otherwise. /// /// Identical logic to the ARM64 variant but expressed in x86_64 System V ABI conventions. -/// Uses callee-saved registers r8–r12 and follows the x86_64 unwind/cleanup ABI expectations. +/// Uses only caller-saved scratch registers so generated callers keep their +/// live SysV callee-saved values intact across the helper call. fn emit_exception_matches_linux_x86_64(emitter: &mut Emitter) { emitter.blank(); emitter.comment("--- runtime: exception_matches ---"); @@ -131,8 +132,8 @@ fn emit_exception_matches_linux_x86_64(emitter: &mut Emitter) { emitter.label("__rt_exception_matches_interface_loop"); emitter.instruction("test r11, r11"); // are there any remaining interfaces to scan for a catch match? emitter.instruction("je __rt_exception_matches_no"); // no remaining interfaces means this catch target does not apply - emitter.instruction("mov r12, QWORD PTR [r10]"); // r12 = current implemented interface_id - emitter.instruction("cmp r12, rsi"); // does this implemented interface match the catch target id? + emitter.instruction("mov rcx, QWORD PTR [r10]"); // rcx = current implemented interface_id without clobbering callee-saved r12 + emitter.instruction("cmp rcx, rsi"); // does this implemented interface match the catch target id? emitter.instruction("je __rt_exception_matches_yes"); // matching interface ids mean the catch clause applies emitter.instruction("add r10, 16"); // advance to the next [interface_id, impl_ptr] pair emitter.instruction("sub r11, 1"); // consume one implemented interface entry @@ -146,3 +147,20 @@ fn emit_exception_matches_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("xor eax, eax"); // return false when the catch type does not match the thrown object emitter.instruction("ret"); // finish the instanceof-style catch test } + +#[cfg(test)] +mod tests { + use super::*; + use crate::codegen_support::platform::{Platform, Target}; + + /// Verifies the x86_64 matcher never clobbers SysV callee-saved registers. + #[test] + fn linux_x86_64_matcher_uses_only_volatile_scratch_registers() { + let mut emitter = Emitter::new(Target::new(Platform::Linux, Arch::X86_64)); + emit_exception_matches(&mut emitter); + let asm = emitter.output(); + + assert!(!asm.contains("r12")); + assert!(asm.contains("mov rcx, QWORD PTR [r10]")); + } +} diff --git a/src/codegen_support/runtime/exceptions/throw_current.rs b/src/codegen_support/runtime/exceptions/throw_current.rs index d9763eff56..c3208de275 100644 --- a/src/codegen_support/runtime/exceptions/throw_current.rs +++ b/src/codegen_support/runtime/exceptions/throw_current.rs @@ -52,7 +52,7 @@ pub fn emit_throw_current(emitter: &mut Emitter) { /// checks for null handler to branch to the uncaught path, calls /// `__rt_exception_cleanup_frames` for frame unwinding, then invokes `longjmp` to transfer /// control to the saved catch resume point. The uncaught path writes 32 bytes to stderr via -/// syscall 1 (write) and terminates via syscall 60 (exit). +/// syscall 1 (write) and terminates via syscall 231 (`exit_group`). fn emit_throw_current_linux_x86_64(emitter: &mut Emitter) { emitter.blank(); emitter.comment("--- runtime: throw_current ---"); diff --git a/src/codegen_support/runtime/io/dirname_levels.rs b/src/codegen_support/runtime/io/dirname_levels.rs index e66e970665..541418a76c 100644 --- a/src/codegen_support/runtime/io/dirname_levels.rs +++ b/src/codegen_support/runtime/io/dirname_levels.rs @@ -97,6 +97,6 @@ fn emit_dirname_levels_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 writes the diagnostic bytes emitter.instruction("syscall"); // emit the invalid dirname levels diagnostic emitter.instruction("mov edi, 1"); // use a failing process exit status for invalid dirname levels - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall 60 exits the process + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 exits the process group emitter.instruction("syscall"); // terminate after the fatal dirname diagnostic } diff --git a/src/codegen_support/runtime/mod.rs b/src/codegen_support/runtime/mod.rs index 8c9016d640..3a96f68298 100644 --- a/src/codegen_support/runtime/mod.rs +++ b/src/codegen_support/runtime/mod.rs @@ -23,6 +23,8 @@ mod fibers; pub(crate) mod generators; mod io; mod objects; +/// PDO Tier-D callback adapters (`__rt_pdo_*`) re-entering compiled-PHP callables. +mod pdo; mod pointers; mod resource_ids; /// Standard PHP library constants, functions, and classes. diff --git a/src/codegen_support/runtime/pdo/mod.rs b/src/codegen_support/runtime/pdo/mod.rs new file mode 100644 index 0000000000..d9d06add91 --- /dev/null +++ b/src/codegen_support/runtime/pdo/mod.rs @@ -0,0 +1,25 @@ +//! Purpose: +//! Groups the PDO Tier-D callback adapters (`__rt_pdo_*`) emitted into the runtime +//! `.text` section. These are the shared, stateless codegen adapters that re-enter +//! compiled-PHP callables on behalf of the `elephc-pdo` bridge: collation +//! comparators, scalar user functions, and aggregate step/finalize callbacks. +//! +//! Called from: +//! - `crate::codegen_support::runtime::emitters::emit_runtime()`, gated by +//! `RuntimeFeatures::pdo_udf` so the family is emitted only when a PDO callback +//! registration is reachable. +//! +//! Key details: +//! - Each adapter is a single `.globl __rt_pdo_*` symbol whose address is taken by +//! the `__elephc_pdo_adapter_addr` builtin and handed to the bridge; the bridge +//! stores and calls it but never references a `__rt_*` symbol directly. + +mod pdo_call_agg_final; +mod pdo_call_agg_step; +mod pdo_call_collation; +mod pdo_call_scalar; + +pub(crate) use pdo_call_agg_final::emit_pdo_call_agg_final; +pub(crate) use pdo_call_agg_step::emit_pdo_call_agg_step; +pub(crate) use pdo_call_collation::emit_pdo_call_collation; +pub(crate) use pdo_call_scalar::emit_pdo_call_scalar; diff --git a/src/codegen_support/runtime/pdo/pdo_call_agg_final.rs b/src/codegen_support/runtime/pdo/pdo_call_agg_final.rs new file mode 100644 index 0000000000..c3a10beff6 --- /dev/null +++ b/src/codegen_support/runtime/pdo/pdo_call_agg_final.rs @@ -0,0 +1,424 @@ +//! Purpose: +//! Emits `__rt_pdo_call_agg_final`, the codegen adapter that re-enters a compiled-PHP +//! aggregate finalize callback on behalf of the `elephc-pdo` SQLite bridge +//! (`Pdo\Sqlite::createAggregate`). It is the once-per-group half of the aggregate +//! pair: the bridge's `x_agg_final` dispatcher calls it after the last row with the +//! group's final accumulator + row count, and it produces the SQL result AND releases +//! the accumulator (finalize is terminal for the group). +//! +//! Called from: +//! - `crate::codegen_support::runtime::emitters::emit_runtime()` via +//! `crate::codegen_support::runtime::pdo`, gated by `RuntimeFeatures::pdo_udf`. +//! - The `elephc-pdo` bridge (`SqliteConn::create_aggregate` → `x_agg_final`), which +//! only stores and calls this adapter's address; it never references a `__rt_*` +//! symbol itself, so the accumulator's final release happens here, not in the bridge. +//! +//! Key details: +//! - C ABI: `__rt_pdo_call_agg_final(descriptor, accumulator, rownumber, out)` +//! returning nothing; the result is written through `out` (a bridge-owned +//! `ElephcResult`, exactly the scalar adapter's protocol). `accumulator` is the +//! group's final boxed-Mixed accumulator (null for an empty group that never +//! stepped); `rownumber` is the step count. +//! - Argument array: `[accumulator, rownumber]` (the PHP `finalize($context, +//! $rownumber)` contract). Slot 0 is the accumulator (`__rt_incref`-ed when +//! non-null; a null accumulator boxes as PHP null); slot 1 is the row count boxed +//! as a Mixed int. +//! - Return decode is identical to the scalar adapter (`__rt_mixed_unbox` once, then +//! int→`out.tag = 1`, float→`out.tag = 2`, string→bytes staged via +//! `elephc_pdo_udf_stash_bytes` then `out.tag = 3`, bool→`out.tag = 5`, null/other→ +//! `out.tag = 0`, throw→`out.tag = -1`). +//! - Accumulator release: after producing the result (or on a throw), the adapter +//! releases the args container (dropping the slot-0 incref) and then releases the +//! accumulator's own group-slot ref — finalize is the terminal use, so the +//! accumulator box is freed exactly once here. +//! - Exception firewall + caller-saved-only register discipline: identical to the +//! scalar / step adapters (the prologue saves only x29/x30 / rbp). + +use crate::codegen_support::callable_descriptor::CALLABLE_DESC_INVOKER_OFFSET; +use crate::codegen_support::try_handlers::{ + TRY_HANDLER_DIAG_DEPTH_OFFSET, TRY_HANDLER_JMP_BUF_OFFSET, +}; +use crate::codegen_support::{abi, emit::Emitter, platform::Arch}; + +// The firewall handler record must match the layout __rt_throw_current assumes: +// next@0, survivor@8, diag@TRY_HANDLER_DIAG_DEPTH_OFFSET, jmp_buf@TRY_HANDLER_JMP_BUF_OFFSET. +const _: () = assert!(TRY_HANDLER_DIAG_DEPTH_OFFSET == 16); +const _: () = assert!(TRY_HANDLER_JMP_BUF_OFFSET == 24); + +/// Emits `__rt_pdo_call_agg_final(descriptor, accumulator, rownumber, out)`. +/// +/// Inputs (AArch64): x0 = descriptor, x1 = accumulator, x2 = rownumber, x3 = out. +/// No return value; the result is written through `out`. +/// (x86_64): rdi, rsi, rdx, rcx in the same order. +pub fn emit_pdo_call_agg_final(emitter: &mut Emitter) { + if emitter.target.arch == Arch::X86_64 { + emit_pdo_call_agg_final_linux_x86_64(emitter); + return; + } + + emitter.blank(); + emitter.comment("--- runtime: pdo_call_agg_final ---"); + emitter.label_global("__rt_pdo_call_agg_final"); + + // Stack frame (304 bytes): + // [sp, #0] = handler record (224 bytes): next@0, survivor@8, diag@16, jmp_buf@24 + // [sp, #224] = descriptor [sp, #232] = accumulator [sp, #240] = rownumber + // [sp, #248] = out ptr [sp, #256] = args array [sp, #264] = boxed args cell + // [sp, #272] = boxed return + // [sp, #288] = saved x29 [sp, #296] = saved x30 + emitter.instruction("sub sp, sp, #304"); // allocate the agg-final adapter frame + emitter.instruction("stp x29, x30, [sp, #288]"); // save frame pointer and return address + emitter.instruction("add x29, sp, #288"); // establish the adapter frame pointer + + emitter.instruction("str x0, [sp, #224]"); // save finalize descriptor pointer + emitter.instruction("str x1, [sp, #232]"); // save the final accumulator (null for an empty group) + emitter.instruction("str x2, [sp, #240]"); // save the row count + emitter.instruction("str x3, [sp, #248]"); // save the result-out pointer + + // -- fast path: no uniform invoker → SQL NULL, but still release the accumulator -- + emitter.instruction(&format!("ldr x9, [x0, #{}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the invoker slot + emitter.instruction("cbz x9, __rt_pdo_call_agg_final_no_invoker"); // no invoker → NULL result + free accumulator + + // -- allocate a 2-slot argument array and stamp value_type = Mixed once -- + emitter.instruction("mov x0, #2"); // capacity: [context, rownumber] + emitter.instruction("mov x1, #8"); // boxed Mixed slots store one pointer each + emitter.instruction("bl __rt_array_new"); // x0 = indexed array backing storage + emitter.instruction("ldr x10, [x0, #-8]"); // load the packed array kind word from the header + emitter.instruction("mov x12, #0x80ff"); // preserve the indexed-array kind and persistent COW flag + emitter.instruction("and x10, x10, x12"); // keep only the persistent metadata bits + emitter.instruction("mov x11, #7"); // value_type tag 7 = boxed Mixed + emitter.instruction("lsl x11, x11, #8"); // move the tag into the packed kind-word byte lane + emitter.instruction("orr x10, x10, x11"); // combine the heap kind with the value_type tag + emitter.instruction("str x10, [x0, #-8]"); // persist the stamped kind word (never re-stamped) + emitter.instruction("str x0, [sp, #256]"); // save the args array pointer + + // -- slot 0: the accumulator (incref when non-null; box PHP null otherwise) -- + emitter.instruction("ldr x0, [sp, #232]"); // final accumulator + emitter.instruction("cbz x0, __rt_pdo_call_agg_final_slot0_null"); // empty group → box PHP null + emitter.instruction("bl __rt_incref"); // retain the accumulator for its args-array slot + emitter.instruction("ldr x0, [sp, #232]"); // reload the accumulator pointer to store + emitter.instruction("b __rt_pdo_call_agg_final_slot0_store"); + emitter.label("__rt_pdo_call_agg_final_slot0_null"); + emitter.instruction("mov x0, #8"); // runtime tag 8 = Void/NULL + emitter.instruction("mov x1, #0"); // value_lo unused + emitter.instruction("mov x2, #0"); // value_hi unused + emitter.instruction("bl __rt_mixed_from_value"); // x0 = boxed PHP null + emitter.label("__rt_pdo_call_agg_final_slot0_store"); + emitter.instruction("ldr x10, [sp, #256]"); // args array pointer (caller-saved temp) + emitter.instruction("str x0, [x10, #24]"); // store the accumulator/null into slot 0 + emitter.instruction("mov x11, #1"); // running element count = 1 + emitter.instruction("str x11, [x10, #0]"); // update the array length field + + // -- slot 1: the row count boxed as a Mixed int -- + emitter.instruction("mov x0, #0"); // runtime tag 0 = int + emitter.instruction("ldr x1, [sp, #240]"); // value_lo = row count + emitter.instruction("mov x2, #0"); // value_hi unused + emitter.instruction("bl __rt_mixed_from_value"); // x0 = boxed Mixed(int) row count + emitter.instruction("ldr x10, [sp, #256]"); // args array pointer + emitter.instruction("str x0, [x10, #32]"); // store the row count into slot 1 + emitter.instruction("mov x11, #2"); // running element count = 2 + emitter.instruction("str x11, [x10, #0]"); // update the array length field + + // -- box the indexed array as a Mixed cell (tag 4 increfs the array) -- + emitter.instruction("ldr x1, [sp, #256]"); // raw args array pointer → payload lo + emitter.instruction("mov x2, #0"); // payload hi unused for an array + emitter.instruction("mov x0, #4"); // runtime tag 4 = indexed array + emitter.instruction("bl __rt_mixed_from_value"); // x0 = boxed Mixed argument cell + emitter.instruction("str x0, [sp, #264]"); // save the boxed Mixed argument cell + + // -- push a setjmp firewall handler around the invoke -- + abi::emit_load_symbol_to_reg(emitter, "x10", "_exc_handler_top", 0); + emitter.instruction("str x10, [sp, #0]"); // handler record: previous handler-stack top + abi::emit_load_symbol_to_reg(emitter, "x10", "_exc_call_frame_top", 0); + emitter.instruction("str x10, [sp, #8]"); // handler record: activation frame to survive a throw + abi::emit_load_symbol_to_reg(emitter, "x10", "_rt_diag_suppression", 0); + emitter.instruction("str x10, [sp, #16]"); // handler record: saved diagnostic-suppression depth + emitter.instruction("mov x10, sp"); // x10 = address of this handler record + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // link the record as the active handler + emitter.instruction("add x0, sp, #24"); // x0 = &jmp_buf inside the handler record + emitter.bl_c("setjmp"); // returns 0 on first pass, 1 when a throw longjmps back + emitter.instruction("cbnz x0, __rt_pdo_call_agg_final_threw"); // nonzero → arrived via longjmp + + // -- normal path: invoke the finalize callback through its descriptor (offset 56) -- + emitter.instruction("ldr x0, [sp, #224]"); // arg0 = descriptor pointer + emitter.instruction("ldr x1, [sp, #264]"); // arg1 = boxed Mixed argument cell + emitter.instruction(&format!("ldr x9, [x0, #{}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the uniform invoker pointer + emitter.instruction("blr x9"); // invoke finalize(...) → OWNED boxed Mixed return in x0 + emitter.instruction("str x0, [sp, #272]"); // save the boxed return for decode + release + + // pop the firewall handler before any further runtime calls + emitter.instruction("ldr x10, [sp, #0]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("ldr x10, [sp, #16]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "x10", "_rt_diag_suppression", 0); // restore it + + // -- type-preserving decode: unbox once and dispatch on the runtime tag -- + emitter.instruction("ldr x0, [sp, #272]"); // boxed return + emitter.instruction("bl __rt_mixed_unbox"); // x0 = tag, x1 = lo, x2 = hi (tag-7 wrappers peeled) + emitter.instruction("cmp x0, #0"); // Mixed int? + emitter.instruction("b.eq __rt_pdo_call_agg_final_ret_int"); + emitter.instruction("cmp x0, #2"); // Mixed float? + emitter.instruction("b.eq __rt_pdo_call_agg_final_ret_float"); + emitter.instruction("cmp x0, #1"); // Mixed string? + emitter.instruction("b.eq __rt_pdo_call_agg_final_ret_string"); + emitter.instruction("cmp x0, #3"); // Mixed bool? + emitter.instruction("b.eq __rt_pdo_call_agg_final_ret_bool"); + // -- tag 8 (null) or any non-scalar → SQL NULL -- + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("str xzr, [x11, #0]"); // out.tag = 0 (NULL) + emitter.instruction("b __rt_pdo_call_agg_final_release_return"); + emitter.label("__rt_pdo_call_agg_final_ret_int"); + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #1"); // ElephcResult tag 1 = INT + emitter.instruction("str x10, [x11, #0]"); // out.tag = 1 + emitter.instruction("str x1, [x11, #8]"); // out.i = lo (int64 value) + emitter.instruction("b __rt_pdo_call_agg_final_release_return"); + emitter.label("__rt_pdo_call_agg_final_ret_float"); + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #2"); // ElephcResult tag 2 = FLOAT + emitter.instruction("str x10, [x11, #0]"); // out.tag = 2 + emitter.instruction("str x1, [x11, #16]"); // out.f = lo (raw f64 bit-pattern → stored as f64) + emitter.instruction("b __rt_pdo_call_agg_final_release_return"); + emitter.label("__rt_pdo_call_agg_final_ret_bool"); + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #5"); // ElephcResult tag 5 = BOOL + emitter.instruction("str x10, [x11, #0]"); // out.tag = 5 + emitter.instruction("str x1, [x11, #8]"); // out.i = lo (0/1) + emitter.instruction("b __rt_pdo_call_agg_final_release_return"); + // -- string: stage the bytes into the bridge BEFORE releasing the owned box -- + emitter.label("__rt_pdo_call_agg_final_ret_string"); + emitter.instruction("mov x0, x1"); // stash arg0 = byte pointer (unbox lo) + emitter.instruction("mov x1, x2"); // stash arg1 = byte length (unbox hi) + emitter.instruction("mov x2, #0"); // stash arg2 = is_blob 0 (return text) + emitter.bl_c("elephc_pdo_udf_stash_bytes"); // deep-copy the string bytes into the bridge's stash + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #3"); // ElephcResult tag 3 = TEXT (bytes live in the stash) + emitter.instruction("str x10, [x11, #0]"); // out.tag = 3 + emitter.instruction("b __rt_pdo_call_agg_final_release_return"); + + // -- release the owned boxed return, then join the accumulator-release cleanup -- + emitter.label("__rt_pdo_call_agg_final_release_return"); + emitter.instruction("ldr x0, [sp, #272]"); // boxed return + emitter.instruction("bl __rt_decref_mixed"); // release the invoker's owned return (bytes already staged) + emitter.instruction("b __rt_pdo_call_agg_final_cleanup"); // join the shared cleanup path + + // -- longjmp path: the finalize callback threw -- + emitter.label("__rt_pdo_call_agg_final_threw"); + emitter.instruction("ldr x10, [sp, #0]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("ldr x10, [sp, #16]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "x10", "_rt_diag_suppression", 0); // restore it + abi::emit_store_zero_to_symbol(emitter, "_exc_value", 0); // swallow the pending exception (surfaced as a SQL error) + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #-1"); // ElephcResult tag -1 = ERROR + emitter.instruction("str x10, [x11, #0]"); // out.tag = -1 + + // -- shared cleanup: release the argument container, then the accumulator (terminal) -- + emitter.label("__rt_pdo_call_agg_final_cleanup"); + emitter.instruction("ldr x0, [sp, #264]"); // boxed Mixed argument cell + emitter.instruction("bl __rt_decref_mixed"); // release the cell (drops the array ref boxing took) + emitter.instruction("ldr x0, [sp, #256]"); // raw args array pointer + emitter.instruction("bl __rt_decref_any"); // release the array (drops the slot-0 accumulator incref) + emitter.instruction("ldr x0, [sp, #232]"); // the accumulator + emitter.instruction("cbz x0, __rt_pdo_call_agg_final_done"); // empty group had no accumulator to free + emitter.instruction("bl __rt_decref_mixed"); // finalize is terminal → free the accumulator's group ref + emitter.label("__rt_pdo_call_agg_final_done"); + emitter.instruction("ldp x29, x30, [sp, #288]"); // restore frame pointer and return address + emitter.instruction("add sp, sp, #304"); // release the adapter frame + emitter.instruction("ret"); // return to the bridge dispatcher (result is in *out) + + // -- fast path: no uniform invoker → NULL result; still free the accumulator -- + emitter.label("__rt_pdo_call_agg_final_no_invoker"); + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("str xzr, [x11, #0]"); // out.tag = 0 (NULL) + emitter.instruction("ldr x0, [sp, #232]"); // the accumulator + emitter.instruction("cbz x0, __rt_pdo_call_agg_final_no_invoker_done"); // nothing to free + emitter.instruction("bl __rt_decref_mixed"); // free the accumulator's group ref (terminal) + emitter.label("__rt_pdo_call_agg_final_no_invoker_done"); + emitter.instruction("ldp x29, x30, [sp, #288]"); // restore frame pointer and return address + emitter.instruction("add sp, sp, #304"); // release the adapter frame + emitter.instruction("ret"); // return to the bridge dispatcher +} + +/// x86_64 implementation of `__rt_pdo_call_agg_final`. +fn emit_pdo_call_agg_final_linux_x86_64(emitter: &mut Emitter) { + emitter.blank(); + emitter.comment("--- runtime: pdo_call_agg_final ---"); + emitter.label_global("__rt_pdo_call_agg_final"); + + // Frame (288 bytes below rbp): + // [rbp-8] descriptor [rbp-16] accumulator [rbp-24] rownumber [rbp-32] out ptr + // [rbp-40] args array [rbp-48] boxed args cell [rbp-56] boxed return + // [rbp-288] handler record (224 bytes): next@[rbp-288], survivor@[rbp-280], + // diag@[rbp-272], jmp_buf base@[rbp-264]. + emitter.instruction("push rbp"); // preserve the caller frame pointer + emitter.instruction("mov rbp, rsp"); // establish the adapter frame pointer + emitter.instruction("sub rsp, 288"); // reserve the slots and the 224-byte handler record + + emitter.instruction("mov QWORD PTR [rbp - 8], rdi"); // save finalize descriptor pointer + emitter.instruction("mov QWORD PTR [rbp - 16], rsi"); // save the final accumulator + emitter.instruction("mov QWORD PTR [rbp - 24], rdx"); // save the row count + emitter.instruction("mov QWORD PTR [rbp - 32], rcx"); // save the result-out pointer + + // -- fast path: no uniform invoker → SQL NULL, but still release the accumulator -- + emitter.instruction(&format!("mov r10, QWORD PTR [rdi + {}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the invoker slot + emitter.instruction("test r10, r10"); // does the descriptor expose a uniform invoker? + emitter.instruction("jz __rt_pdo_call_agg_final_no_invoker_x86"); // no invoker → NULL + free accumulator + + // -- allocate a 2-slot argument array and stamp value_type = Mixed once -- + emitter.instruction("mov rdi, 2"); // capacity: [context, rownumber] + emitter.instruction("mov rsi, 8"); // boxed Mixed slots store one pointer each + emitter.instruction("call __rt_array_new"); // rax = indexed array backing storage + emitter.instruction("mov r10, QWORD PTR [rax - 8]"); // load the packed array kind word from the header + emitter.instruction("mov r11, 0xffffffff000080ff"); // preserve heap marker + indexed-array kind + COW bit + emitter.instruction("and r10, r11"); // keep only the persistent metadata bits + emitter.instruction("mov r11, 7"); // value_type tag 7 = boxed Mixed + emitter.instruction("shl r11, 8"); // move the tag into the packed kind-word byte lane + emitter.instruction("or r10, r11"); // combine the heap kind with the value_type tag + emitter.instruction("mov QWORD PTR [rax - 8], r10"); // persist the stamped kind word (never re-stamped) + emitter.instruction("mov QWORD PTR [rbp - 40], rax"); // save the args array pointer + + // -- slot 0: the accumulator (incref when non-null; box PHP null otherwise) -- + emitter.instruction("mov rax, QWORD PTR [rbp - 16]"); // final accumulator (incref reads RAX) + emitter.instruction("test rax, rax"); // empty group? + emitter.instruction("jz __rt_pdo_call_agg_final_slot0_null_x86"); // yes → box PHP null + emitter.instruction("call __rt_incref"); // retain the accumulator for its args-array slot + emitter.instruction("mov rax, QWORD PTR [rbp - 16]"); // reload the accumulator pointer to store + emitter.instruction("jmp __rt_pdo_call_agg_final_slot0_store_x86"); + emitter.label("__rt_pdo_call_agg_final_slot0_null_x86"); + emitter.instruction("mov rdi, 0"); // value_lo unused + emitter.instruction("mov rsi, 0"); // value_hi unused + emitter.instruction("mov eax, 8"); // runtime tag 8 = Void/NULL + emitter.instruction("call __rt_mixed_from_value"); // rax = boxed PHP null + emitter.label("__rt_pdo_call_agg_final_slot0_store_x86"); + emitter.instruction("mov r10, QWORD PTR [rbp - 40]"); // args array pointer + emitter.instruction("mov QWORD PTR [r10 + 24], rax"); // store the accumulator/null into slot 0 + emitter.instruction("mov QWORD PTR [r10], 1"); // update the array length field to 1 + + // -- slot 1: the row count boxed as a Mixed int -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 24]"); // value_lo = row count + emitter.instruction("xor esi, esi"); // value_hi unused + emitter.instruction("mov eax, 0"); // runtime tag 0 = int + emitter.instruction("call __rt_mixed_from_value"); // rax = boxed Mixed(int) row count + emitter.instruction("mov r10, QWORD PTR [rbp - 40]"); // args array pointer + emitter.instruction("mov QWORD PTR [r10 + 32], rax"); // store the row count into slot 1 + emitter.instruction("mov QWORD PTR [r10], 2"); // update the array length field to 2 + + // -- box the indexed array as a Mixed cell (tag 4 increfs the array) -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 40]"); // raw args array pointer → payload lo + emitter.instruction("xor esi, esi"); // payload hi unused for an array + emitter.instruction("mov eax, 4"); // runtime tag 4 = indexed array + emitter.instruction("call __rt_mixed_from_value"); // rax = boxed Mixed argument cell + emitter.instruction("mov QWORD PTR [rbp - 48], rax"); // save the boxed Mixed argument cell + + // -- push a setjmp firewall handler around the invoke -- + abi::emit_load_symbol_to_reg(emitter, "r10", "_exc_handler_top", 0); + emitter.instruction("mov QWORD PTR [rbp - 288], r10"); // handler record: record.next + abi::emit_load_symbol_to_reg(emitter, "r10", "_exc_call_frame_top", 0); + emitter.instruction("mov QWORD PTR [rbp - 280], r10"); // handler record: survivor frame + abi::emit_load_symbol_to_reg(emitter, "r10", "_rt_diag_suppression", 0); + emitter.instruction("mov QWORD PTR [rbp - 272], r10"); // handler record: saved diagnostic depth + emitter.instruction("lea r10, [rbp - 288]"); // r10 = address of this handler record + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // link the record as the active handler + emitter.instruction("lea rdi, [rbp - 264]"); // rdi = &jmp_buf inside the handler record (record + 24) + emitter.bl_c("setjmp"); // returns 0 on first pass, 1 when a throw longjmps back + emitter.instruction("test rax, rax"); // did control arrive via longjmp? + emitter.instruction("jne __rt_pdo_call_agg_final_threw_x86"); // nonzero → arrived via longjmp + + // -- normal path: invoke the finalize callback through its descriptor (offset 56) -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 8]"); // arg0 = descriptor pointer + emitter.instruction("mov rsi, QWORD PTR [rbp - 48]"); // arg1 = boxed Mixed argument cell + emitter.instruction(&format!("mov r10, QWORD PTR [rdi + {}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the uniform invoker pointer + emitter.instruction("call r10"); // invoke finalize(...) → OWNED boxed Mixed return in rax + emitter.instruction("mov QWORD PTR [rbp - 56], rax"); // save the boxed return for decode + release + + // pop the firewall handler before any further runtime calls + emitter.instruction("mov r10, QWORD PTR [rbp - 288]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("mov r10, QWORD PTR [rbp - 272]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "r10", "_rt_diag_suppression", 0); // restore it + + // -- type-preserving decode: unbox once and dispatch on the runtime tag -- + emitter.instruction("mov rax, QWORD PTR [rbp - 56]"); // boxed return (unbox reads RAX) + emitter.instruction("call __rt_mixed_unbox"); // rax = tag, rdi = lo, rdx = hi + emitter.instruction("cmp rax, 0"); // Mixed int? + emitter.instruction("je __rt_pdo_call_agg_final_ret_int_x86"); + emitter.instruction("cmp rax, 2"); // Mixed float? + emitter.instruction("je __rt_pdo_call_agg_final_ret_float_x86"); + emitter.instruction("cmp rax, 1"); // Mixed string? + emitter.instruction("je __rt_pdo_call_agg_final_ret_string_x86"); + emitter.instruction("cmp rax, 3"); // Mixed bool? + emitter.instruction("je __rt_pdo_call_agg_final_ret_bool_x86"); + // -- tag 8 (null) or any non-scalar → SQL NULL -- + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 0"); // out.tag = 0 (NULL) + emitter.instruction("jmp __rt_pdo_call_agg_final_release_return_x86"); + emitter.label("__rt_pdo_call_agg_final_ret_int_x86"); + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 1"); // out.tag = 1 (INT) + emitter.instruction("mov QWORD PTR [r11 + 8], rdi"); // out.i = lo (int64 value) + emitter.instruction("jmp __rt_pdo_call_agg_final_release_return_x86"); + emitter.label("__rt_pdo_call_agg_final_ret_float_x86"); + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 2"); // out.tag = 2 (FLOAT) + emitter.instruction("mov QWORD PTR [r11 + 16], rdi"); // out.f = lo (raw f64 bit-pattern → stored as f64) + emitter.instruction("jmp __rt_pdo_call_agg_final_release_return_x86"); + emitter.label("__rt_pdo_call_agg_final_ret_bool_x86"); + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 5"); // out.tag = 5 (BOOL) + emitter.instruction("mov QWORD PTR [r11 + 8], rdi"); // out.i = lo (0/1) + emitter.instruction("jmp __rt_pdo_call_agg_final_release_return_x86"); + // -- string: stage the bytes into the bridge BEFORE releasing the owned box -- + emitter.label("__rt_pdo_call_agg_final_ret_string_x86"); + emitter.instruction("mov rsi, rdx"); // stash arg1 = byte length (unbox hi), before rdx is reused + emitter.instruction("xor edx, edx"); // stash arg2 = is_blob 0 (return text) + // rdi already holds the unbox lo (byte pointer) = stash arg0. + emitter.bl_c("elephc_pdo_udf_stash_bytes"); // deep-copy the string bytes into the bridge's stash + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 3"); // out.tag = 3 (TEXT; bytes live in the stash) + emitter.instruction("jmp __rt_pdo_call_agg_final_release_return_x86"); + + // -- release the owned boxed return, then join the accumulator-release cleanup -- + emitter.label("__rt_pdo_call_agg_final_release_return_x86"); + emitter.instruction("mov rax, QWORD PTR [rbp - 56]"); // boxed return + emitter.instruction("call __rt_decref_mixed"); // release the invoker's owned return (bytes already staged) + emitter.instruction("jmp __rt_pdo_call_agg_final_cleanup_x86"); // join the shared cleanup path + + // -- longjmp path: the finalize callback threw -- + emitter.label("__rt_pdo_call_agg_final_threw_x86"); + emitter.instruction("mov r10, QWORD PTR [rbp - 288]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("mov r10, QWORD PTR [rbp - 272]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "r10", "_rt_diag_suppression", 0); // restore it + abi::emit_store_zero_to_symbol(emitter, "_exc_value", 0); // swallow the pending exception + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], -1"); // out.tag = -1 (ERROR) + + // -- shared cleanup: release the argument container, then the accumulator (terminal) -- + emitter.label("__rt_pdo_call_agg_final_cleanup_x86"); + emitter.instruction("mov rax, QWORD PTR [rbp - 48]"); // boxed Mixed argument cell + emitter.instruction("call __rt_decref_mixed"); // release the cell (drops the array ref boxing took) + emitter.instruction("mov rax, QWORD PTR [rbp - 40]"); // raw args array pointer + emitter.instruction("call __rt_decref_any"); // release the array (drops the slot-0 accumulator incref) + emitter.instruction("mov rax, QWORD PTR [rbp - 16]"); // the accumulator + emitter.instruction("test rax, rax"); // empty group had no accumulator? + emitter.instruction("jz __rt_pdo_call_agg_final_done_x86"); // nothing to free + emitter.instruction("call __rt_decref_mixed"); // finalize is terminal → free the accumulator's group ref + emitter.label("__rt_pdo_call_agg_final_done_x86"); + emitter.instruction("add rsp, 288"); // release the adapter frame + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return to the bridge dispatcher (result is in *out) + + // -- fast path: no uniform invoker → NULL result; still free the accumulator -- + emitter.label("__rt_pdo_call_agg_final_no_invoker_x86"); + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 0"); // out.tag = 0 (NULL) + emitter.instruction("mov rax, QWORD PTR [rbp - 16]"); // the accumulator + emitter.instruction("test rax, rax"); // nothing to free? + emitter.instruction("jz __rt_pdo_call_agg_final_no_invoker_done_x86"); + emitter.instruction("call __rt_decref_mixed"); // free the accumulator's group ref (terminal) + emitter.label("__rt_pdo_call_agg_final_no_invoker_done_x86"); + emitter.instruction("add rsp, 288"); // release the adapter frame + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return to the bridge dispatcher +} diff --git a/src/codegen_support/runtime/pdo/pdo_call_agg_step.rs b/src/codegen_support/runtime/pdo/pdo_call_agg_step.rs new file mode 100644 index 0000000000..8befac2e07 --- /dev/null +++ b/src/codegen_support/runtime/pdo/pdo_call_agg_step.rs @@ -0,0 +1,475 @@ +//! Purpose: +//! Emits `__rt_pdo_call_agg_step`, the codegen adapter that re-enters a compiled-PHP +//! aggregate step callback on behalf of the `elephc-pdo` SQLite bridge +//! (`Pdo\Sqlite::createAggregate`). It is the per-row half of the aggregate pair: the +//! bridge's `x_agg_step` dispatcher calls it once per row with the group's running +//! accumulator + row number + the SQLite row values, and it returns the new +//! accumulator the callback produced. +//! +//! Called from: +//! - `crate::codegen_support::runtime::emitters::emit_runtime()` via +//! `crate::codegen_support::runtime::pdo`, gated by `RuntimeFeatures::pdo_udf`. +//! - The `elephc-pdo` bridge (`SqliteConn::create_aggregate` → `x_agg_step`), which +//! only stores and calls this adapter's address; it never references a `__rt_*` +//! symbol itself (respecting the non-whole-archive linker boundary), so ALL of the +//! accumulator's refcount traffic happens here, not in the bridge. +//! +//! Key details: +//! - C ABI: `__rt_pdo_call_agg_step(descriptor, accumulator, rownumber, argv, argc, +//! threw) -> new_accumulator`. `accumulator` is the group's current boxed-Mixed +//! accumulator (null before the first row); `argv`/`argc` are the row's `ElephcVal[]` +//! (40-byte stride, same as the scalar adapter); `threw` is an out-param i64 the +//! adapter sets to 1 iff the callback threw. The owned new accumulator is returned; +//! the bridge stores it into `AggCtx.accumulator`. +//! - Argument array: `[accumulator, rownumber, ...rowValues]` (the two prepended slots +//! are the PHP `step($context, $rownumber, ...$values)` contract). Slot 0 is the +//! accumulator (`__rt_incref`-ed when non-null so the args container's later release +//! balances it; a null accumulator boxes as PHP null instead); slot 1 is the row +//! number boxed as a Mixed int; slots 2.. are the row values boxed exactly as the +//! scalar adapter's loop does. +//! - Refcount protocol: on the NORMAL path the adapter releases the args container +//! (dropping the slot-0 incref) and then releases the OLD accumulator's own ref (it +//! is being replaced), returning the callback's owned result. If the callback +//! returned `$context` (aliasing the old accumulator), the invoker's return-incref +//! keeps it alive at exactly one ref. On the THROW path the adapter releases the +//! args container (still dropping the slot-0 incref) but does NOT release the old +//! accumulator — it stays valid in the group's slot so `x_agg_final` can free it — +//! sets `*threw = 1`, and returns null. +//! - Exception firewall: identical 224-byte setjmp handler record as the scalar / +//! collation adapters. A compiled-PHP `throw` inside the step callback longjmps back +//! here rather than unwinding across SQLite's VDBE and the Rust bridge frame. +//! - Registers: the body uses ONLY caller-saved scratch (aarch64 x9-x15; x86_64 +//! r8-r11 + rax/rcx/rdi/rsi) since the prologue saves only x29/x30 (rbp): clobbering +//! a callee-saved register would corrupt a value the bridge's `x_agg_step` caller +//! holds live across the call. + +use crate::codegen_support::callable_descriptor::CALLABLE_DESC_INVOKER_OFFSET; +use crate::codegen_support::try_handlers::{ + TRY_HANDLER_DIAG_DEPTH_OFFSET, TRY_HANDLER_JMP_BUF_OFFSET, +}; +use crate::codegen_support::{abi, emit::Emitter, platform::Arch}; + +// The firewall handler record must match the layout __rt_throw_current assumes: +// next@0, survivor@8, diag@TRY_HANDLER_DIAG_DEPTH_OFFSET, jmp_buf@TRY_HANDLER_JMP_BUF_OFFSET. +const _: () = assert!(TRY_HANDLER_DIAG_DEPTH_OFFSET == 16); +const _: () = assert!(TRY_HANDLER_JMP_BUF_OFFSET == 24); + +/// Emits `__rt_pdo_call_agg_step(descriptor, accumulator, rownumber, argv, argc, threw) +/// -> new_accumulator`. +/// +/// Inputs (AArch64): x0 = descriptor, x1 = accumulator, x2 = rownumber, x3 = argv, +/// x4 = argc, x5 = threw (out-param). Result in x0 = owned new accumulator. +/// (x86_64): rdi, rsi, rdx, rcx, r8, r9 in the same order; result in rax. +pub fn emit_pdo_call_agg_step(emitter: &mut Emitter) { + if emitter.target.arch == Arch::X86_64 { + emit_pdo_call_agg_step_linux_x86_64(emitter); + return; + } + + emitter.blank(); + emitter.comment("--- runtime: pdo_call_agg_step ---"); + emitter.label_global("__rt_pdo_call_agg_step"); + + // Stack frame (320 bytes): + // [sp, #0] = handler record (224 bytes): next@0, survivor@8, diag@16, jmp_buf@24 + // [sp, #224] = descriptor [sp, #232] = accumulator [sp, #240] = rownumber + // [sp, #248] = argv [sp, #256] = argc [sp, #264] = threw ptr + // [sp, #272] = args array [sp, #280] = boxed args cell + // [sp, #288] = boxed return [sp, #296] = loop index + // [sp, #304] = saved x29 [sp, #312] = saved x30 + emitter.instruction("sub sp, sp, #320"); // allocate the agg-step adapter frame + emitter.instruction("stp x29, x30, [sp, #304]"); // save frame pointer and return address + emitter.instruction("add x29, sp, #304"); // establish the adapter frame pointer + + emitter.instruction("str x0, [sp, #224]"); // save step descriptor pointer + emitter.instruction("str x1, [sp, #232]"); // save current accumulator (null before the first row) + emitter.instruction("str x2, [sp, #240]"); // save row number + emitter.instruction("str x3, [sp, #248]"); // save argv base pointer + emitter.instruction("str x4, [sp, #256]"); // save row argument count + emitter.instruction("str x5, [sp, #264]"); // save the threw out-param pointer + + // -- initialise *threw = 0 (no exception unless the firewall fires) -- + emitter.instruction("ldr x9, [sp, #264]"); // threw out-param pointer + emitter.instruction("str xzr, [x9]"); // *threw = 0 + + // -- fast path: no uniform invoker → return the accumulator unchanged -- + emitter.instruction(&format!("ldr x9, [x0, #{}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the invoker slot + emitter.instruction("cbz x9, __rt_pdo_call_agg_step_no_invoker"); // no invoker → pass the accumulator straight through + + // -- allocate an (argc + 2)-slot argument array and stamp value_type = Mixed once -- + emitter.instruction("ldr x0, [sp, #256]"); // row argument count + emitter.instruction("add x0, x0, #2"); // + 2 for the prepended [context, rownumber] slots + emitter.instruction("mov x1, #8"); // boxed Mixed slots store one pointer each + emitter.instruction("bl __rt_array_new"); // x0 = indexed array backing storage + emitter.instruction("ldr x10, [x0, #-8]"); // load the packed array kind word from the header + emitter.instruction("mov x12, #0x80ff"); // preserve the indexed-array kind and persistent COW flag + emitter.instruction("and x10, x10, x12"); // keep only the persistent metadata bits + emitter.instruction("mov x11, #7"); // value_type tag 7 = boxed Mixed + emitter.instruction("lsl x11, x11, #8"); // move the tag into the packed kind-word byte lane + emitter.instruction("orr x10, x10, x11"); // combine the heap kind with the value_type tag + emitter.instruction("str x10, [x0, #-8]"); // persist the stamped kind word (never re-stamped: no push_int) + emitter.instruction("str x0, [sp, #272]"); // save the args array pointer + + // -- slot 0: the current accumulator (incref when non-null; box PHP null otherwise) -- + emitter.instruction("ldr x0, [sp, #232]"); // current accumulator + emitter.instruction("cbz x0, __rt_pdo_call_agg_step_slot0_null"); // null before the first row → box PHP null + emitter.instruction("bl __rt_incref"); // retain the accumulator for its args-array slot + emitter.instruction("ldr x0, [sp, #232]"); // reload the accumulator pointer to store + emitter.instruction("b __rt_pdo_call_agg_step_slot0_store"); + emitter.label("__rt_pdo_call_agg_step_slot0_null"); + emitter.instruction("mov x0, #8"); // runtime tag 8 = Void/NULL + emitter.instruction("mov x1, #0"); // value_lo unused + emitter.instruction("mov x2, #0"); // value_hi unused + emitter.instruction("bl __rt_mixed_from_value"); // x0 = boxed PHP null + emitter.label("__rt_pdo_call_agg_step_slot0_store"); + emitter.instruction("ldr x10, [sp, #272]"); // args array pointer (caller-saved temp) + emitter.instruction("str x0, [x10, #24]"); // store the accumulator/null into slot 0 + emitter.instruction("mov x11, #1"); // running element count = 1 + emitter.instruction("str x11, [x10, #0]"); // update the array length field + + // -- slot 1: the row number boxed as a Mixed int -- + emitter.instruction("mov x0, #0"); // runtime tag 0 = int + emitter.instruction("ldr x1, [sp, #240]"); // value_lo = row number + emitter.instruction("mov x2, #0"); // value_hi unused + emitter.instruction("bl __rt_mixed_from_value"); // x0 = boxed Mixed(int) row number + emitter.instruction("ldr x10, [sp, #272]"); // args array pointer + emitter.instruction("str x0, [x10, #32]"); // store the row number into slot 1 + emitter.instruction("mov x11, #2"); // running element count = 2 + emitter.instruction("str x11, [x10, #0]"); // update the array length field + + // -- boxing loop: box each row ElephcVal into slots 2.. -- + emitter.instruction("str xzr, [sp, #296]"); // loop index k = 0 + emitter.label("__rt_pdo_call_agg_step_loop"); + emitter.instruction("ldr x9, [sp, #296]"); // reload the loop index + emitter.instruction("ldr x10, [sp, #256]"); // reload the row argument count + emitter.instruction("cmp x9, x10"); // have all row values been boxed? + emitter.instruction("b.ge __rt_pdo_call_agg_step_loop_done"); // yes → box the container and invoke + emitter.instruction("ldr x11, [sp, #248]"); // reload the argv base pointer + emitter.instruction("mov x12, #40"); // ElephcVal stride (tag@0,i@8,f@16,ptr@24,len@32) + emitter.instruction("mul x13, x9, x12"); // byte offset of ElephcVal[k] + emitter.instruction("add x11, x11, x13"); // x11 = &argv[k] + emitter.instruction("ldr x14, [x11, #0]"); // load the ElephcVal storage-class tag + emitter.instruction("cmp x14, #1"); // SQLITE_INTEGER? + emitter.instruction("b.eq __rt_pdo_call_agg_step_box_int"); + emitter.instruction("cmp x14, #2"); // SQLITE_FLOAT? + emitter.instruction("b.eq __rt_pdo_call_agg_step_box_float"); + emitter.instruction("cmp x14, #3"); // SQLITE_TEXT? + emitter.instruction("b.eq __rt_pdo_call_agg_step_box_str"); + emitter.instruction("cmp x14, #4"); // SQLITE_BLOB? + emitter.instruction("b.eq __rt_pdo_call_agg_step_box_str"); + // -- tag 0 (SQLITE_NULL) or any unexpected code → PHP null (Mixed tag 8) -- + emitter.instruction("mov x0, #8"); // runtime tag 8 = Void/NULL + emitter.instruction("mov x1, #0"); // value_lo unused + emitter.instruction("mov x2, #0"); // value_hi unused + emitter.instruction("b __rt_pdo_call_agg_step_box_call"); + emitter.label("__rt_pdo_call_agg_step_box_int"); + emitter.instruction("mov x0, #0"); // runtime tag 0 = int + emitter.instruction("ldr x1, [x11, #8]"); // value_lo = ElephcVal.i + emitter.instruction("mov x2, #0"); // value_hi unused + emitter.instruction("b __rt_pdo_call_agg_step_box_call"); + emitter.label("__rt_pdo_call_agg_step_box_float"); + emitter.instruction("mov x0, #2"); // runtime tag 2 = float + emitter.instruction("ldr x1, [x11, #16]"); // value_lo = ElephcVal.f raw f64 bit-pattern + emitter.instruction("mov x2, #0"); // value_hi unused + emitter.instruction("b __rt_pdo_call_agg_step_box_call"); + emitter.label("__rt_pdo_call_agg_step_box_str"); + emitter.instruction("mov x0, #1"); // runtime tag 1 = string (binary-safe) + emitter.instruction("ldr x1, [x11, #24]"); // value_lo = ElephcVal.ptr + emitter.instruction("ldr x2, [x11, #32]"); // value_hi = ElephcVal.len + emitter.instruction("b __rt_pdo_call_agg_step_box_call"); + emitter.label("__rt_pdo_call_agg_step_box_call"); + emitter.instruction("bl __rt_mixed_from_value"); // x0 = owned boxed Mixed row value + emitter.instruction("ldr x9, [sp, #296]"); // reload the loop index (clobbered by the call) + emitter.instruction("ldr x10, [sp, #272]"); // reload the args array pointer (caller-saved temp) + emitter.instruction("add x12, x9, #2"); // element index = k + 2 (past [context, rownumber]) + emitter.instruction("lsl x12, x12, #3"); // (k + 2) * 8 + emitter.instruction("add x12, x12, #24"); // element region begins 24 bytes past the header + emitter.instruction("str x0, [x10, x12]"); // store the boxed row value into slot k+2 + emitter.instruction("add x9, x9, #1"); // advance the loop index + emitter.instruction("str x9, [sp, #296]"); // persist the loop index + emitter.instruction("add x11, x9, #2"); // total element count = (k+1) + 2 + emitter.instruction("str x11, [x10, #0]"); // update the array length field + emitter.instruction("b __rt_pdo_call_agg_step_loop"); // box the next row value + emitter.label("__rt_pdo_call_agg_step_loop_done"); + + // -- box the indexed array as a Mixed cell (tag 4 increfs the array) -- + emitter.instruction("ldr x1, [sp, #272]"); // raw args array pointer → payload lo + emitter.instruction("mov x2, #0"); // payload hi unused for an array + emitter.instruction("mov x0, #4"); // runtime tag 4 = indexed array + emitter.instruction("bl __rt_mixed_from_value"); // x0 = boxed Mixed argument cell + emitter.instruction("str x0, [sp, #280]"); // save the boxed Mixed argument cell + + // -- push a setjmp firewall handler around the invoke -- + abi::emit_load_symbol_to_reg(emitter, "x10", "_exc_handler_top", 0); + emitter.instruction("str x10, [sp, #0]"); // handler record: previous handler-stack top + abi::emit_load_symbol_to_reg(emitter, "x10", "_exc_call_frame_top", 0); + emitter.instruction("str x10, [sp, #8]"); // handler record: activation frame to survive a throw + abi::emit_load_symbol_to_reg(emitter, "x10", "_rt_diag_suppression", 0); + emitter.instruction("str x10, [sp, #16]"); // handler record: saved diagnostic-suppression depth + emitter.instruction("mov x10, sp"); // x10 = address of this handler record + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // link the record as the active handler + emitter.instruction("add x0, sp, #24"); // x0 = &jmp_buf inside the handler record + emitter.bl_c("setjmp"); // returns 0 on first pass, 1 when a throw longjmps back + emitter.instruction("cbnz x0, __rt_pdo_call_agg_step_threw"); // nonzero → arrived via longjmp + + // -- normal path: invoke the step callback through its descriptor (offset 56) -- + emitter.instruction("ldr x0, [sp, #224]"); // arg0 = descriptor pointer + emitter.instruction("ldr x1, [sp, #280]"); // arg1 = boxed Mixed argument cell + emitter.instruction(&format!("ldr x9, [x0, #{}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the uniform invoker pointer + emitter.instruction("blr x9"); // invoke step(...) → OWNED boxed Mixed new accumulator in x0 + emitter.instruction("str x0, [sp, #288]"); // save the new accumulator + + // pop the firewall handler before any further runtime calls + emitter.instruction("ldr x10, [sp, #0]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("ldr x10, [sp, #16]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "x10", "_rt_diag_suppression", 0); // restore it + + // -- release the args container (drops the slot-0 accumulator incref) -- + emitter.instruction("ldr x0, [sp, #280]"); // boxed Mixed argument cell + emitter.instruction("bl __rt_decref_mixed"); // release the cell (drops the array ref boxing took) + emitter.instruction("ldr x0, [sp, #272]"); // raw args array pointer + emitter.instruction("bl __rt_decref_any"); // release the array and deep-free its boxed args + + // -- release the OLD accumulator's own ref (it is being replaced) -- + emitter.instruction("ldr x0, [sp, #232]"); // old accumulator + emitter.instruction("cbz x0, __rt_pdo_call_agg_step_return"); // null (first row) → nothing to release + emitter.instruction("bl __rt_decref_mixed"); // drop the old accumulator's group-slot ref + emitter.label("__rt_pdo_call_agg_step_return"); + emitter.instruction("ldr x0, [sp, #288]"); // return the owned new accumulator + emitter.instruction("ldp x29, x30, [sp, #304]"); // restore frame pointer and return address + emitter.instruction("add sp, sp, #320"); // release the adapter frame + emitter.instruction("ret"); // return the new accumulator to the bridge dispatcher + + // -- longjmp path: the step callback threw -- + emitter.label("__rt_pdo_call_agg_step_threw"); + emitter.instruction("ldr x10, [sp, #0]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("ldr x10, [sp, #16]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "x10", "_rt_diag_suppression", 0); // restore it + abi::emit_store_zero_to_symbol(emitter, "_exc_value", 0); // swallow the pending exception (surfaced as a SQL error) + // release the args container (drops the slot-0 incref) but PRESERVE the accumulator + emitter.instruction("ldr x0, [sp, #280]"); // boxed Mixed argument cell + emitter.instruction("bl __rt_decref_mixed"); // release the cell + emitter.instruction("ldr x0, [sp, #272]"); // raw args array pointer + emitter.instruction("bl __rt_decref_any"); // release the array (the slot-0 incref is dropped here) + emitter.instruction("ldr x9, [sp, #264]"); // threw out-param pointer + emitter.instruction("mov x10, #1"); // signal the callback threw + emitter.instruction("str x10, [x9]"); // *threw = 1 + emitter.instruction("mov x0, #0"); // return null (the bridge preserves the old accumulator) + emitter.instruction("ldp x29, x30, [sp, #304]"); // restore frame pointer and return address + emitter.instruction("add sp, sp, #320"); // release the adapter frame + emitter.instruction("ret"); // return to the bridge dispatcher + + // -- fast path: no uniform invoker → pass the accumulator through, nothing allocated -- + emitter.label("__rt_pdo_call_agg_step_no_invoker"); + emitter.instruction("ldr x0, [sp, #232]"); // return the accumulator unchanged + emitter.instruction("ldp x29, x30, [sp, #304]"); // restore frame pointer and return address + emitter.instruction("add sp, sp, #320"); // release the adapter frame + emitter.instruction("ret"); // return to the bridge dispatcher +} + +/// x86_64 implementation of `__rt_pdo_call_agg_step`. +fn emit_pdo_call_agg_step_linux_x86_64(emitter: &mut Emitter) { + emitter.blank(); + emitter.comment("--- runtime: pdo_call_agg_step ---"); + emitter.label_global("__rt_pdo_call_agg_step"); + + // Frame (304 bytes below rbp): + // [rbp-8] descriptor [rbp-16] accumulator [rbp-24] rownumber [rbp-32] argv + // [rbp-40] argc [rbp-48] threw ptr [rbp-56] args array [rbp-64] boxed args cell + // [rbp-72] boxed return [rbp-80] loop index + // [rbp-304] handler record (224 bytes): next@[rbp-304], survivor@[rbp-296], + // diag@[rbp-288], jmp_buf base@[rbp-280]. + emitter.instruction("push rbp"); // preserve the caller frame pointer + emitter.instruction("mov rbp, rsp"); // establish the adapter frame pointer + emitter.instruction("sub rsp, 304"); // reserve the slots and the 224-byte handler record + + emitter.instruction("mov QWORD PTR [rbp - 8], rdi"); // save step descriptor pointer + emitter.instruction("mov QWORD PTR [rbp - 16], rsi"); // save current accumulator + emitter.instruction("mov QWORD PTR [rbp - 24], rdx"); // save row number + emitter.instruction("mov QWORD PTR [rbp - 32], rcx"); // save argv base pointer + emitter.instruction("mov QWORD PTR [rbp - 40], r8"); // save row argument count + emitter.instruction("mov QWORD PTR [rbp - 48], r9"); // save the threw out-param pointer + + // -- initialise *threw = 0 -- + emitter.instruction("mov rax, QWORD PTR [rbp - 48]"); // threw out-param pointer + emitter.instruction("mov QWORD PTR [rax], 0"); // *threw = 0 + + // -- fast path: no uniform invoker → return the accumulator unchanged -- + emitter.instruction(&format!("mov r10, QWORD PTR [rdi + {}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the invoker slot + emitter.instruction("test r10, r10"); // does the descriptor expose a uniform invoker? + emitter.instruction("jz __rt_pdo_call_agg_step_no_invoker_x86"); // no invoker → pass the accumulator straight through + + // -- allocate an (argc + 2)-slot argument array and stamp value_type = Mixed once -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 40]"); // row argument count + emitter.instruction("add rdi, 2"); // + 2 for the prepended [context, rownumber] slots + emitter.instruction("mov rsi, 8"); // boxed Mixed slots store one pointer each + emitter.instruction("call __rt_array_new"); // rax = indexed array backing storage + emitter.instruction("mov r10, QWORD PTR [rax - 8]"); // load the packed array kind word from the header + emitter.instruction("mov r11, 0xffffffff000080ff"); // preserve heap marker + indexed-array kind + COW bit + emitter.instruction("and r10, r11"); // keep only the persistent metadata bits + emitter.instruction("mov r11, 7"); // value_type tag 7 = boxed Mixed + emitter.instruction("shl r11, 8"); // move the tag into the packed kind-word byte lane + emitter.instruction("or r10, r11"); // combine the heap kind with the value_type tag + emitter.instruction("mov QWORD PTR [rax - 8], r10"); // persist the stamped kind word (never re-stamped) + emitter.instruction("mov QWORD PTR [rbp - 56], rax"); // save the args array pointer + + // -- slot 0: the current accumulator (incref when non-null; box PHP null otherwise) -- + emitter.instruction("mov rax, QWORD PTR [rbp - 16]"); // current accumulator (incref reads RAX) + emitter.instruction("test rax, rax"); // null before the first row? + emitter.instruction("jz __rt_pdo_call_agg_step_slot0_null_x86"); // yes → box PHP null + emitter.instruction("call __rt_incref"); // retain the accumulator for its args-array slot + emitter.instruction("mov rax, QWORD PTR [rbp - 16]"); // reload the accumulator pointer to store + emitter.instruction("jmp __rt_pdo_call_agg_step_slot0_store_x86"); + emitter.label("__rt_pdo_call_agg_step_slot0_null_x86"); + emitter.instruction("mov rdi, 0"); // value_lo unused + emitter.instruction("mov rsi, 0"); // value_hi unused + emitter.instruction("mov eax, 8"); // runtime tag 8 = Void/NULL + emitter.instruction("call __rt_mixed_from_value"); // rax = boxed PHP null + emitter.label("__rt_pdo_call_agg_step_slot0_store_x86"); + emitter.instruction("mov r10, QWORD PTR [rbp - 56]"); // args array pointer + emitter.instruction("mov QWORD PTR [r10 + 24], rax"); // store the accumulator/null into slot 0 + emitter.instruction("mov QWORD PTR [r10], 1"); // update the array length field to 1 + + // -- slot 1: the row number boxed as a Mixed int -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 24]"); // value_lo = row number + emitter.instruction("xor esi, esi"); // value_hi unused + emitter.instruction("mov eax, 0"); // runtime tag 0 = int + emitter.instruction("call __rt_mixed_from_value"); // rax = boxed Mixed(int) row number + emitter.instruction("mov r10, QWORD PTR [rbp - 56]"); // args array pointer + emitter.instruction("mov QWORD PTR [r10 + 32], rax"); // store the row number into slot 1 + emitter.instruction("mov QWORD PTR [r10], 2"); // update the array length field to 2 + + // -- boxing loop: box each row ElephcVal into slots 2.. -- + emitter.instruction("mov QWORD PTR [rbp - 80], 0"); // loop index k = 0 + emitter.label("__rt_pdo_call_agg_step_loop_x86"); + emitter.instruction("mov r9, QWORD PTR [rbp - 80]"); // reload the loop index + emitter.instruction("mov r10, QWORD PTR [rbp - 40]"); // reload the row argument count + emitter.instruction("cmp r9, r10"); // have all row values been boxed? + emitter.instruction("jge __rt_pdo_call_agg_step_loop_done_x86"); // yes → box the container and invoke + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // reload the argv base pointer + emitter.instruction("imul rax, r9, 40"); // byte offset of ElephcVal[k] + emitter.instruction("add r11, rax"); // r11 = &argv[k] + emitter.instruction("mov r8, QWORD PTR [r11 + 0]"); // load the ElephcVal storage-class tag + emitter.instruction("cmp r8, 1"); // SQLITE_INTEGER? + emitter.instruction("je __rt_pdo_call_agg_step_box_int_x86"); + emitter.instruction("cmp r8, 2"); // SQLITE_FLOAT? + emitter.instruction("je __rt_pdo_call_agg_step_box_float_x86"); + emitter.instruction("cmp r8, 3"); // SQLITE_TEXT? + emitter.instruction("je __rt_pdo_call_agg_step_box_str_x86"); + emitter.instruction("cmp r8, 4"); // SQLITE_BLOB? + emitter.instruction("je __rt_pdo_call_agg_step_box_str_x86"); + // -- tag 0 (SQLITE_NULL) or any unexpected code → PHP null (Mixed tag 8) -- + emitter.instruction("mov eax, 8"); // runtime tag 8 = Void/NULL + emitter.instruction("xor edi, edi"); // value_lo unused + emitter.instruction("xor esi, esi"); // value_hi unused + emitter.instruction("jmp __rt_pdo_call_agg_step_box_call_x86"); + emitter.label("__rt_pdo_call_agg_step_box_int_x86"); + emitter.instruction("mov eax, 0"); // runtime tag 0 = int + emitter.instruction("mov rdi, QWORD PTR [r11 + 8]"); // value_lo = ElephcVal.i + emitter.instruction("xor esi, esi"); // value_hi unused + emitter.instruction("jmp __rt_pdo_call_agg_step_box_call_x86"); + emitter.label("__rt_pdo_call_agg_step_box_float_x86"); + emitter.instruction("mov eax, 2"); // runtime tag 2 = float + emitter.instruction("mov rdi, QWORD PTR [r11 + 16]"); // value_lo = ElephcVal.f raw f64 bit-pattern + emitter.instruction("xor esi, esi"); // value_hi unused + emitter.instruction("jmp __rt_pdo_call_agg_step_box_call_x86"); + emitter.label("__rt_pdo_call_agg_step_box_str_x86"); + emitter.instruction("mov eax, 1"); // runtime tag 1 = string (binary-safe) + emitter.instruction("mov rdi, QWORD PTR [r11 + 24]"); // value_lo = ElephcVal.ptr + emitter.instruction("mov rsi, QWORD PTR [r11 + 32]"); // value_hi = ElephcVal.len + emitter.instruction("jmp __rt_pdo_call_agg_step_box_call_x86"); + emitter.label("__rt_pdo_call_agg_step_box_call_x86"); + emitter.instruction("call __rt_mixed_from_value"); // rax = owned boxed Mixed row value + emitter.instruction("mov r9, QWORD PTR [rbp - 80]"); // reload the loop index (clobbered by the call) + emitter.instruction("mov r11, QWORD PTR [rbp - 56]"); // reload the args array pointer + emitter.instruction("mov rcx, r9"); // compute the element byte offset + emitter.instruction("add rcx, 2"); // element index = k + 2 (past [context, rownumber]) + emitter.instruction("shl rcx, 3"); // (k + 2) * 8 + emitter.instruction("add rcx, 24"); // element region begins 24 bytes past the header + emitter.instruction("mov QWORD PTR [r11 + rcx], rax"); // store the boxed row value into slot k+2 + emitter.instruction("add r9, 1"); // advance the loop index + emitter.instruction("mov QWORD PTR [rbp - 80], r9"); // persist the loop index + emitter.instruction("add r9, 2"); // total element count = (k+1) + 2 + emitter.instruction("mov QWORD PTR [r11], r9"); // update the array length field + emitter.instruction("jmp __rt_pdo_call_agg_step_loop_x86"); // box the next row value + emitter.label("__rt_pdo_call_agg_step_loop_done_x86"); + + // -- box the indexed array as a Mixed cell (tag 4 increfs the array) -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 56]"); // raw args array pointer → payload lo + emitter.instruction("xor esi, esi"); // payload hi unused for an array + emitter.instruction("mov eax, 4"); // runtime tag 4 = indexed array + emitter.instruction("call __rt_mixed_from_value"); // rax = boxed Mixed argument cell + emitter.instruction("mov QWORD PTR [rbp - 64], rax"); // save the boxed Mixed argument cell + + // -- push a setjmp firewall handler around the invoke -- + abi::emit_load_symbol_to_reg(emitter, "r10", "_exc_handler_top", 0); + emitter.instruction("mov QWORD PTR [rbp - 304], r10"); // handler record: record.next + abi::emit_load_symbol_to_reg(emitter, "r10", "_exc_call_frame_top", 0); + emitter.instruction("mov QWORD PTR [rbp - 296], r10"); // handler record: survivor frame + abi::emit_load_symbol_to_reg(emitter, "r10", "_rt_diag_suppression", 0); + emitter.instruction("mov QWORD PTR [rbp - 288], r10"); // handler record: saved diagnostic depth + emitter.instruction("lea r10, [rbp - 304]"); // r10 = address of this handler record + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // link the record as the active handler + emitter.instruction("lea rdi, [rbp - 280]"); // rdi = &jmp_buf inside the handler record (record + 24) + emitter.bl_c("setjmp"); // returns 0 on first pass, 1 when a throw longjmps back + emitter.instruction("test rax, rax"); // did control arrive via longjmp? + emitter.instruction("jne __rt_pdo_call_agg_step_threw_x86"); // nonzero → arrived via longjmp + + // -- normal path: invoke the step callback through its descriptor (offset 56) -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 8]"); // arg0 = descriptor pointer + emitter.instruction("mov rsi, QWORD PTR [rbp - 64]"); // arg1 = boxed Mixed argument cell + emitter.instruction(&format!("mov r10, QWORD PTR [rdi + {}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the uniform invoker pointer + emitter.instruction("call r10"); // invoke step(...) → OWNED boxed Mixed new accumulator in rax + emitter.instruction("mov QWORD PTR [rbp - 72], rax"); // save the new accumulator + + // pop the firewall handler before any further runtime calls + emitter.instruction("mov r10, QWORD PTR [rbp - 304]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("mov r10, QWORD PTR [rbp - 288]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "r10", "_rt_diag_suppression", 0); // restore it + + // -- release the args container (drops the slot-0 accumulator incref) -- + emitter.instruction("mov rax, QWORD PTR [rbp - 64]"); // boxed Mixed argument cell + emitter.instruction("call __rt_decref_mixed"); // release the cell + emitter.instruction("mov rax, QWORD PTR [rbp - 56]"); // raw args array pointer + emitter.instruction("call __rt_decref_any"); // release the array and deep-free its boxed args + + // -- release the OLD accumulator's own ref (it is being replaced) -- + emitter.instruction("mov rax, QWORD PTR [rbp - 16]"); // old accumulator + emitter.instruction("test rax, rax"); // null (first row)? + emitter.instruction("jz __rt_pdo_call_agg_step_return_x86"); // yes → nothing to release + emitter.instruction("call __rt_decref_mixed"); // drop the old accumulator's group-slot ref + emitter.label("__rt_pdo_call_agg_step_return_x86"); + emitter.instruction("mov rax, QWORD PTR [rbp - 72]"); // return the owned new accumulator + emitter.instruction("add rsp, 304"); // release the adapter frame + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return the new accumulator to the bridge dispatcher + + // -- longjmp path: the step callback threw -- + emitter.label("__rt_pdo_call_agg_step_threw_x86"); + emitter.instruction("mov r10, QWORD PTR [rbp - 304]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("mov r10, QWORD PTR [rbp - 288]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "r10", "_rt_diag_suppression", 0); // restore it + abi::emit_store_zero_to_symbol(emitter, "_exc_value", 0); // swallow the pending exception + // release the args container (drops the slot-0 incref) but PRESERVE the accumulator + emitter.instruction("mov rax, QWORD PTR [rbp - 64]"); // boxed Mixed argument cell + emitter.instruction("call __rt_decref_mixed"); // release the cell + emitter.instruction("mov rax, QWORD PTR [rbp - 56]"); // raw args array pointer + emitter.instruction("call __rt_decref_any"); // release the array (the slot-0 incref is dropped here) + emitter.instruction("mov rax, QWORD PTR [rbp - 48]"); // threw out-param pointer + emitter.instruction("mov QWORD PTR [rax], 1"); // *threw = 1 + emitter.instruction("xor eax, eax"); // return null (the bridge preserves the old accumulator) + emitter.instruction("add rsp, 304"); // release the adapter frame + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return to the bridge dispatcher + + // -- fast path: no uniform invoker → pass the accumulator through, nothing allocated -- + emitter.label("__rt_pdo_call_agg_step_no_invoker_x86"); + emitter.instruction("mov rax, QWORD PTR [rbp - 16]"); // return the accumulator unchanged + emitter.instruction("add rsp, 304"); // release the adapter frame + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return to the bridge dispatcher +} diff --git a/src/codegen_support/runtime/pdo/pdo_call_collation.rs b/src/codegen_support/runtime/pdo/pdo_call_collation.rs new file mode 100644 index 0000000000..3b232efa80 --- /dev/null +++ b/src/codegen_support/runtime/pdo/pdo_call_collation.rs @@ -0,0 +1,326 @@ +//! Purpose: +//! Emits `__rt_pdo_call_collation`, the codegen adapter that re-enters a +//! compiled-PHP collation comparator on behalf of the `elephc-pdo` bridge. It is +//! the runtime half of the PDO Tier-D "decompose-at-PHP" design: the bridge stores +//! this adapter's address (obtained through `__elephc_pdo_adapter_addr(0)`) together +//! with the callable's descriptor pointer per SQLite registration, and its +//! `x_compare` dispatcher calls back here with the two byte buffers SQLite provides. +//! +//! Called from: +//! - `crate::codegen_support::runtime::emitters::emit_runtime()` via +//! `crate::codegen_support::runtime::pdo`, gated by `RuntimeFeatures::pdo_udf`. +//! - The `elephc-pdo` bridge (`SqliteConn::create_collation` → `x_compare`), which +//! only stores and calls this adapter's address; it never references a `__rt_*` +//! symbol itself (respecting the non-whole-archive linker boundary). +//! +//! Key details: +//! - C ABI: `__rt_pdo_call_collation(descriptor, a_ptr, a_len, b_ptr, b_len) -> i64` +//! returning the comparison sign; the bridge clamps it to -1/0/1. +//! - Marshalling mirrors `__rt_http_fire_notification` (the offset-56 uniform +//! invoker path): the two transient SQLite `(ptr, len)` buffers are boxed as owned +//! Mixed strings via `__rt_mixed_from_value` (tag 1), which deep-copies through +//! `__rt_str_persist`, so SQLite's buffers may be invalidated the moment this +//! returns. The boxed strings fill a two-slot `value_type = 7` (Mixed) indexed +//! array, which is boxed as a Mixed cell and passed as the invoker's argument +//! container. The owned boxed return is `__rt_mixed_cast_int`-ed to the sign and +//! released, then the container (cell + raw array + element boxes) is released. +//! - Exception firewall: a compiled-PHP `throw` is a `longjmp` to the nearest +//! handler. Without interception it would `longjmp` over SQLite's VDBE and this +//! Rust/bridge frame — leaving the bridge mutex locked (deadlock), running +//! `Drop` across a `longjmp` (UB), or terminating the process. So the adapter +//! pushes its own `setjmp` handler record (identical 224-byte layout to the EIR +//! try/catch slot) around the invoke: on a normal return it pops the handler and +//! returns the comparator sign; on a `longjmp` it pops the handler, swallows the +//! pending exception (SQLite's `xCompare` has no error channel), and returns 0 +//! (equal). Surfacing the exception at the query boundary is a later hardening +//! step; the load-bearing guarantee here is that the `throw` never unwinds past +//! this C boundary. + +use crate::codegen_support::callable_descriptor::CALLABLE_DESC_INVOKER_OFFSET; +use crate::codegen_support::try_handlers::{ + TRY_HANDLER_DIAG_DEPTH_OFFSET, TRY_HANDLER_JMP_BUF_OFFSET, +}; +use crate::codegen_support::{abi, emit::Emitter, platform::Arch}; + +// The firewall builds its handler record by hand and must match the layout the EIR +// try/catch machinery and `__rt_throw_current` assume: next@0, survivor@8, +// diag@TRY_HANDLER_DIAG_DEPTH_OFFSET, jmp_buf@TRY_HANDLER_JMP_BUF_OFFSET. Assert the +// two ABI-critical offsets at compile time so a constant drift breaks the build +// here rather than corrupting a `longjmp` at runtime. +const _: () = assert!(TRY_HANDLER_DIAG_DEPTH_OFFSET == 16); +const _: () = assert!(TRY_HANDLER_JMP_BUF_OFFSET == 24); + +/// Emits `__rt_pdo_call_collation(descriptor, a_ptr, a_len, b_ptr, b_len) -> sign`. +/// +/// Inputs (AArch64): x0 = descriptor, x1 = a_ptr, x2 = a_len, x3 = b_ptr, +/// x4 = b_len. Result in x0 = comparison sign (bridge clamps to -1/0/1). +/// (x86_64): rdi, rsi, rdx, rcx, r8 in the same order; result in rax. +pub fn emit_pdo_call_collation(emitter: &mut Emitter) { + if emitter.target.arch == Arch::X86_64 { + emit_pdo_call_collation_linux_x86_64(emitter); + return; + } + + emitter.blank(); + emitter.comment("--- runtime: pdo_call_collation ---"); + emitter.label_global("__rt_pdo_call_collation"); + + // Stack frame (320 bytes): + // [sp, #0] = handler record (224 bytes): next@0, survivor@8, diag@16, + // jmp_buf@24 (matches TRY_HANDLER_* / __rt_throw_current). + // [sp, #224] = descriptor [sp, #232] = a_ptr [sp, #240] = a_len + // [sp, #248] = b_ptr [sp, #256] = b_len + // [sp, #264] = args array ptr [sp, #272] = boxed args cell + // [sp, #280] = boxed return [sp, #288] = comparator sign + // [sp, #304] = saved x29 [sp, #312] = saved x30 + emitter.instruction("sub sp, sp, #320"); // allocate the collation-adapter frame + emitter.instruction("stp x29, x30, [sp, #304]"); // save frame pointer and return address + emitter.instruction("add x29, sp, #304"); // establish the adapter frame pointer + + emitter.instruction("str x0, [sp, #224]"); // save descriptor pointer + emitter.instruction("str x1, [sp, #232]"); // save a_ptr + emitter.instruction("str x2, [sp, #240]"); // save a_len + emitter.instruction("str x3, [sp, #248]"); // save b_ptr + emitter.instruction("str x4, [sp, #256]"); // save b_len + + // -- fast path: a descriptor without a uniform invoker compares as equal -- + emitter.instruction(&format!("ldr x9, [x0, #{}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the invoker slot + emitter.instruction("cbz x9, __rt_pdo_call_collation_ret_zero"); // no invoker → sign 0, nothing to release + + // -- allocate a 2-slot argument array and stamp value_type = Mixed once -- + emitter.instruction("mov x0, #2"); // capacity: two comparator arguments + emitter.instruction("mov x1, #8"); // boxed Mixed slots store one pointer each + emitter.instruction("bl __rt_array_new"); // x0 = indexed array backing storage + emitter.instruction("ldr x10, [x0, #-8]"); // load the packed array kind word from the header + emitter.instruction("mov x12, #0x80ff"); // preserve the indexed-array kind and persistent COW flag + emitter.instruction("and x10, x10, x12"); // keep only the persistent metadata bits + emitter.instruction("mov x11, #7"); // value_type tag 7 = boxed Mixed + emitter.instruction("lsl x11, x11, #8"); // move the tag into the packed kind-word byte lane + emitter.instruction("orr x10, x10, x11"); // combine the heap kind with the value_type tag + emitter.instruction("str x10, [x0, #-8]"); // persist the stamped kind word (never re-stamped: no push_int) + emitter.instruction("str x0, [sp, #264]"); // save the args array pointer + + // -- slot 0: string a (from_value tag 1 persists the bytes into an owned copy) -- + emitter.instruction("mov x0, #1"); // runtime tag 1 = string + emitter.instruction("ldr x1, [sp, #232]"); // value_lo = a_ptr + emitter.instruction("ldr x2, [sp, #240]"); // value_hi = a_len + emitter.instruction("bl __rt_mixed_from_value"); // x0 = boxed Mixed(string) a + emitter.instruction("ldr x9, [sp, #264]"); // reload the args array pointer + emitter.instruction("str x0, [x9, #24]"); // store boxed a into slot 0 (data region + 0) + emitter.instruction("mov x10, #1"); // running element count = 1 + emitter.instruction("str x10, [x9]"); // update the array length field + + // -- slot 1: string b -- + emitter.instruction("mov x0, #1"); // runtime tag 1 = string + emitter.instruction("ldr x1, [sp, #248]"); // value_lo = b_ptr + emitter.instruction("ldr x2, [sp, #256]"); // value_hi = b_len + emitter.instruction("bl __rt_mixed_from_value"); // x0 = boxed Mixed(string) b + emitter.instruction("ldr x9, [sp, #264]"); // reload the args array pointer + emitter.instruction("str x0, [x9, #32]"); // store boxed b into slot 1 (data region + 8) + emitter.instruction("mov x10, #2"); // running element count = 2 + emitter.instruction("str x10, [x9]"); // update the array length field + + // -- box the indexed array as a Mixed cell (tag 4 increfs the array) -- + emitter.instruction("ldr x1, [sp, #264]"); // raw args array pointer → payload lo + emitter.instruction("mov x2, #0"); // payload hi unused for an array + emitter.instruction("mov x0, #4"); // runtime tag 4 = indexed array + emitter.instruction("bl __rt_mixed_from_value"); // x0 = boxed Mixed argument cell + emitter.instruction("str x0, [sp, #272]"); // save the boxed Mixed argument cell + + // -- push a setjmp firewall handler around the invoke -- + // record.next = _exc_handler_top + abi::emit_load_symbol_to_reg(emitter, "x10", "_exc_handler_top", 0); + emitter.instruction("str x10, [sp, #0]"); // handler record: previous handler-stack top + // record.survivor = live _exc_call_frame_top → cleanup stops at this C boundary + abi::emit_load_symbol_to_reg(emitter, "x10", "_exc_call_frame_top", 0); + emitter.instruction("str x10, [sp, #8]"); // handler record: activation frame to survive a throw + // record.diag = _rt_diag_suppression + abi::emit_load_symbol_to_reg(emitter, "x10", "_rt_diag_suppression", 0); + emitter.instruction("str x10, [sp, #16]"); // handler record: saved diagnostic-suppression depth + // _exc_handler_top = &record (record base = sp + 0) + emitter.instruction("mov x10, sp"); // x10 = address of this handler record + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // link the record as the active handler + // setjmp(&jmp_buf) where jmp_buf = record + 24 + emitter.instruction("add x0, sp, #24"); // x0 = &jmp_buf inside the handler record + emitter.bl_c("setjmp"); // returns 0 on first pass, 1 when a throw longjmps back + emitter.instruction("cbnz x0, __rt_pdo_call_collation_threw"); // nonzero → arrived via longjmp + + // -- normal path: invoke the comparator through its descriptor (offset 56) -- + emitter.instruction("ldr x0, [sp, #224]"); // arg0 = descriptor pointer + emitter.instruction("ldr x1, [sp, #272]"); // arg1 = boxed Mixed argument cell + emitter.instruction(&format!("ldr x9, [x0, #{}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the uniform invoker pointer + emitter.instruction("blr x9"); // invoke comparator($a, $b) → OWNED boxed Mixed return in x0 + emitter.instruction("str x0, [sp, #280]"); // save the boxed return for later release + + // pop the firewall handler before any further runtime calls + emitter.instruction("ldr x10, [sp, #0]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("ldr x10, [sp, #16]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "x10", "_rt_diag_suppression", 0); // restore it + + // extract the comparator sign (borrows), then release the owned return + emitter.instruction("ldr x0, [sp, #280]"); // boxed return + emitter.instruction("bl __rt_mixed_cast_int"); // x0 = raw i64 comparator sign (PHP (int) rules) + emitter.instruction("str x0, [sp, #288]"); // save the sign + emitter.instruction("ldr x0, [sp, #280]"); // boxed return + emitter.instruction("bl __rt_decref_mixed"); // release the invoker's owned return + emitter.instruction("b __rt_pdo_call_collation_cleanup"); // join the shared container-release path + + // -- longjmp path: a throw crossed the invoke -- + emitter.label("__rt_pdo_call_collation_threw"); + emitter.instruction("ldr x10, [sp, #0]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("ldr x10, [sp, #16]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "x10", "_rt_diag_suppression", 0); // restore it + abi::emit_store_zero_to_symbol(emitter, "_exc_value", 0); // swallow the pending exception (no xCompare error channel) + emitter.instruction("str xzr, [sp, #288]"); // comparator sign = 0 (treat as equal) + + // -- shared cleanup: release the argument container -- + emitter.label("__rt_pdo_call_collation_cleanup"); + emitter.instruction("ldr x0, [sp, #272]"); // boxed Mixed argument cell + emitter.instruction("bl __rt_decref_mixed"); // release the cell (drops the array ref boxing took) + emitter.instruction("ldr x0, [sp, #264]"); // raw args array pointer + emitter.instruction("bl __rt_decref_any"); // release the array and deep-free its two boxed strings + emitter.instruction("ldr x0, [sp, #288]"); // load the comparator sign into the result register + emitter.instruction("ldp x29, x30, [sp, #304]"); // restore frame pointer and return address + emitter.instruction("add sp, sp, #320"); // release the adapter frame + emitter.instruction("ret"); // return the comparison sign to the bridge dispatcher + + // -- fast path: no uniform invoker → equal, with nothing allocated to release -- + emitter.label("__rt_pdo_call_collation_ret_zero"); + emitter.instruction("mov x0, #0"); // comparator sign = 0 (treat as equal) + emitter.instruction("ldp x29, x30, [sp, #304]"); // restore frame pointer and return address + emitter.instruction("add sp, sp, #320"); // release the adapter frame + emitter.instruction("ret"); // return to the bridge dispatcher +} + +/// x86_64 implementation of `__rt_pdo_call_collation`. +fn emit_pdo_call_collation_linux_x86_64(emitter: &mut Emitter) { + emitter.blank(); + emitter.comment("--- runtime: pdo_call_collation ---"); + emitter.label_global("__rt_pdo_call_collation"); + + // Frame (304 bytes below rbp): + // [rbp-8] descriptor [rbp-16] a_ptr [rbp-24] a_len + // [rbp-32] b_ptr [rbp-40] b_len + // [rbp-48] args array [rbp-56] boxed args cell + // [rbp-64] boxed return [rbp-72] comparator sign + // [rbp-296] handler record (224 bytes): next@0, survivor@8, diag@16, jmp_buf@24 + // → record.next=[rbp-296], survivor=[rbp-288], diag=[rbp-280], + // jmp_buf base=[rbp-272]. + // push rbp + sub rsp,304 keeps rsp 16-aligned for the nested calls. + emitter.instruction("push rbp"); // preserve the caller frame pointer + emitter.instruction("mov rbp, rsp"); // establish the adapter frame pointer + emitter.instruction("sub rsp, 304"); // reserve the slots and the 224-byte handler record + + emitter.instruction("mov QWORD PTR [rbp - 8], rdi"); // save descriptor pointer + emitter.instruction("mov QWORD PTR [rbp - 16], rsi"); // save a_ptr + emitter.instruction("mov QWORD PTR [rbp - 24], rdx"); // save a_len + emitter.instruction("mov QWORD PTR [rbp - 32], rcx"); // save b_ptr + emitter.instruction("mov QWORD PTR [rbp - 40], r8"); // save b_len + + // -- fast path: a descriptor without a uniform invoker compares as equal -- + emitter.instruction(&format!("mov r10, QWORD PTR [rdi + {}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the invoker slot + emitter.instruction("test r10, r10"); // does the descriptor expose a uniform invoker? + emitter.instruction("jz __rt_pdo_call_collation_ret_zero_x86"); // no invoker → sign 0, nothing to release + + // -- allocate a 2-slot argument array and stamp value_type = Mixed once -- + emitter.instruction("mov rdi, 2"); // capacity: two comparator arguments + emitter.instruction("mov rsi, 8"); // boxed Mixed slots store one pointer each + emitter.instruction("call __rt_array_new"); // rax = indexed array backing storage + emitter.instruction("mov r10, QWORD PTR [rax - 8]"); // load the packed array kind word from the header + emitter.instruction("mov r11, 0xffffffff000080ff"); // preserve heap marker + indexed-array kind + COW bit + emitter.instruction("and r10, r11"); // keep only the persistent metadata bits + emitter.instruction("mov r11, 7"); // value_type tag 7 = boxed Mixed + emitter.instruction("shl r11, 8"); // move the tag into the packed kind-word byte lane + emitter.instruction("or r10, r11"); // combine the heap kind with the value_type tag + emitter.instruction("mov QWORD PTR [rax - 8], r10"); // persist the stamped kind word (never re-stamped: no push_int) + emitter.instruction("mov QWORD PTR [rbp - 48], rax"); // save the args array pointer + + // -- slot 0: string a (from_value tag 1 persists the bytes into an owned copy) -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 16]"); // value_lo = a_ptr + emitter.instruction("mov rsi, QWORD PTR [rbp - 24]"); // value_hi = a_len + emitter.instruction("mov eax, 1"); // runtime tag 1 = string (tag goes in RAX) + emitter.instruction("call __rt_mixed_from_value"); // rax = boxed Mixed(string) a + emitter.instruction("mov r10, QWORD PTR [rbp - 48]"); // reload the args array pointer + emitter.instruction("mov QWORD PTR [r10 + 24], rax"); // store boxed a into slot 0 (data region + 0) + emitter.instruction("mov QWORD PTR [r10], 1"); // update the array length field to 1 + + // -- slot 1: string b -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 32]"); // value_lo = b_ptr + emitter.instruction("mov rsi, QWORD PTR [rbp - 40]"); // value_hi = b_len + emitter.instruction("mov eax, 1"); // runtime tag 1 = string + emitter.instruction("call __rt_mixed_from_value"); // rax = boxed Mixed(string) b + emitter.instruction("mov r10, QWORD PTR [rbp - 48]"); // reload the args array pointer + emitter.instruction("mov QWORD PTR [r10 + 32], rax"); // store boxed b into slot 1 (data region + 8) + emitter.instruction("mov QWORD PTR [r10], 2"); // update the array length field to 2 + + // -- box the indexed array as a Mixed cell (tag 4 increfs the array) -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 48]"); // raw args array pointer → payload lo + emitter.instruction("xor esi, esi"); // payload hi unused for an array + emitter.instruction("mov eax, 4"); // runtime tag 4 = indexed array + emitter.instruction("call __rt_mixed_from_value"); // rax = boxed Mixed argument cell + emitter.instruction("mov QWORD PTR [rbp - 56], rax"); // save the boxed Mixed argument cell + + // -- push a setjmp firewall handler around the invoke -- + abi::emit_load_symbol_to_reg(emitter, "r10", "_exc_handler_top", 0); // previous handler-stack top + emitter.instruction("mov QWORD PTR [rbp - 296], r10"); // handler record: record.next + abi::emit_load_symbol_to_reg(emitter, "r10", "_exc_call_frame_top", 0); // live activation-frame top + emitter.instruction("mov QWORD PTR [rbp - 288], r10"); // handler record: survivor frame (cleanup stops here) + abi::emit_load_symbol_to_reg(emitter, "r10", "_rt_diag_suppression", 0); // current diagnostic-suppression depth + emitter.instruction("mov QWORD PTR [rbp - 280], r10"); // handler record: saved diagnostic depth + emitter.instruction("lea r10, [rbp - 296]"); // r10 = address of this handler record + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // link the record as the active handler + emitter.instruction("lea rdi, [rbp - 272]"); // rdi = &jmp_buf inside the handler record (record + 24) + emitter.bl_c("setjmp"); // returns 0 on first pass, 1 when a throw longjmps back + emitter.instruction("test rax, rax"); // did control arrive via longjmp? + emitter.instruction("jne __rt_pdo_call_collation_threw_x86"); // nonzero → arrived via longjmp + + // -- normal path: invoke the comparator through its descriptor (offset 56) -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 8]"); // arg0 = descriptor pointer + emitter.instruction("mov rsi, QWORD PTR [rbp - 56]"); // arg1 = boxed Mixed argument cell + emitter.instruction(&format!("mov r10, QWORD PTR [rdi + {}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the uniform invoker pointer + emitter.instruction("call r10"); // invoke comparator($a, $b) → OWNED boxed Mixed return in rax + emitter.instruction("mov QWORD PTR [rbp - 64], rax"); // save the boxed return for later release + + // pop the firewall handler before any further runtime calls + emitter.instruction("mov r10, QWORD PTR [rbp - 296]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("mov r10, QWORD PTR [rbp - 280]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "r10", "_rt_diag_suppression", 0); // restore it + + // extract the comparator sign (borrows), then release the owned return + emitter.instruction("mov rax, QWORD PTR [rbp - 64]"); // boxed return (cast_int reads RAX) + emitter.instruction("call __rt_mixed_cast_int"); // rax = raw i64 comparator sign (PHP (int) rules) + emitter.instruction("mov QWORD PTR [rbp - 72], rax"); // save the sign + emitter.instruction("mov rax, QWORD PTR [rbp - 64]"); // boxed return + emitter.instruction("call __rt_decref_mixed"); // release the invoker's owned return + emitter.instruction("jmp __rt_pdo_call_collation_cleanup_x86"); // join the shared container-release path + + // -- longjmp path: a throw crossed the invoke -- + emitter.label("__rt_pdo_call_collation_threw_x86"); + emitter.instruction("mov r10, QWORD PTR [rbp - 296]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("mov r10, QWORD PTR [rbp - 280]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "r10", "_rt_diag_suppression", 0); // restore it + abi::emit_store_zero_to_symbol(emitter, "_exc_value", 0); // swallow the pending exception (no xCompare error channel) + emitter.instruction("mov QWORD PTR [rbp - 72], 0"); // comparator sign = 0 (treat as equal) + + // -- shared cleanup: release the argument container -- + emitter.label("__rt_pdo_call_collation_cleanup_x86"); + emitter.instruction("mov rax, QWORD PTR [rbp - 56]"); // boxed Mixed argument cell + emitter.instruction("call __rt_decref_mixed"); // release the cell (drops the array ref boxing took) + emitter.instruction("mov rax, QWORD PTR [rbp - 48]"); // raw args array pointer + emitter.instruction("call __rt_decref_any"); // release the array and deep-free its two boxed strings + emitter.instruction("mov rax, QWORD PTR [rbp - 72]"); // load the comparator sign into the result register + emitter.instruction("add rsp, 304"); // release the adapter frame + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return the comparison sign to the bridge dispatcher + + // -- fast path: no uniform invoker → equal, with nothing allocated to release -- + emitter.label("__rt_pdo_call_collation_ret_zero_x86"); + emitter.instruction("xor eax, eax"); // comparator sign = 0 (treat as equal) + emitter.instruction("add rsp, 304"); // release the adapter frame + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return to the bridge dispatcher +} diff --git a/src/codegen_support/runtime/pdo/pdo_call_scalar.rs b/src/codegen_support/runtime/pdo/pdo_call_scalar.rs new file mode 100644 index 0000000000..d0d3f07ba6 --- /dev/null +++ b/src/codegen_support/runtime/pdo/pdo_call_scalar.rs @@ -0,0 +1,531 @@ +//! Purpose: +//! Emits `__rt_pdo_call_scalar`, the codegen adapter that re-enters a compiled-PHP +//! scalar SQL user function on behalf of the `elephc-pdo` bridge. It is the runtime +//! half of the PDO Tier-D "decompose-at-PHP" design for `Pdo\Sqlite::createFunction`: +//! the bridge stores this adapter's address (obtained through +//! `__elephc_pdo_adapter_addr(1)`) together with the callable's descriptor pointer per +//! SQLite registration, and its `x_scalar` dispatcher calls back here once per row with +//! the argument vector SQLite provides. +//! +//! Called from: +//! - `crate::codegen_support::runtime::emitters::emit_runtime()` via +//! `crate::codegen_support::runtime::pdo`, gated by `RuntimeFeatures::pdo_udf`. +//! - The `elephc-pdo` bridge (`SqliteConn::create_function` → `x_scalar`), which only +//! stores and calls this adapter's address; it never references a `__rt_*` symbol +//! itself (respecting the non-whole-archive linker boundary). +//! +//! Key details: +//! - C ABI: `__rt_pdo_call_scalar(descriptor, argv, argc, out)` returning nothing; the +//! result is written through `out` (a bridge-owned `ElephcResult`). `argv` is a +//! contiguous array of `argc` `ElephcVal` records (40 bytes each: tag@0, i@8, f@16, +//! ptr@24, len@32), and `out` is an `ElephcResult` (24 bytes: tag@0, i@8, f@16). +//! - Argument boxing (unlike the fixed two-string collation build) is a dynamic loop +//! over `argc`: each `ElephcVal` is translated from its SQLite storage-class tag +//! (1=INT, 2=FLOAT, 3=TEXT, 4=BLOB, 0=NULL) into a runtime Mixed tag (INT→0, FLOAT→2 +//! with the f64 bit-pattern kept in the integer lo register, TEXT/BLOB→1 string, +//! NULL→8) and boxed through `__rt_mixed_from_value` into a `value_type = 7` (Mixed) +//! indexed args array. `__rt_mixed_from_value` with tag 1 deep-copies the bytes via +//! `__rt_str_persist`, so SQLite's transient argv buffers may be invalidated the +//! moment this returns. The array is boxed as a Mixed cell (tag 4) and passed as the +//! invoker's argument container. +//! - Return decoding is type-preserving (mirroring the bind path rather than a lossy +//! `__rt_mixed_cast_*`): the owned boxed return is `__rt_mixed_unbox`-ed once and the +//! tag dispatched — int→`out.tag = 1`, float→`out.tag = 2` (raw f64 bits into +//! `out.f`), string→bytes staged into the bridge via `elephc_pdo_udf_stash_bytes` +//! then `out.tag = 3`, bool→`out.tag = 5`, null→`out.tag = 0`, arrays→6, and +//! objects/callables→7. The latter two let the bridge reject unsupported callback +//! results instead of silently converting them to SQL NULL. The boxed return +//! is released after the bytes are staged (the stash deep-copies them out of the +//! about-to-be-freed cell). +//! - Exception firewall: identical to the collation adapter. A compiled-PHP `throw` is +//! a `longjmp`; letting it cross this C boundary would unwind over SQLite's VDBE and +//! the Rust bridge frame (deadlock/UB/exit). The adapter pushes its own `setjmp` +//! handler record (the same 224-byte layout as the EIR try/catch slot) around the +//! invoke; on a `longjmp` it pops the handler, swallows the pending exception, and +//! writes `out.tag = -1`, which the bridge dispatcher turns into a `sqlite3_result_error` +//! (surfacing as a PDOException at the query boundary). Re-raising the original +//! exception object is a later hardening step; the load-bearing guarantee is that the +//! `throw` never unwinds past this C boundary. + +use crate::codegen_support::callable_descriptor::CALLABLE_DESC_INVOKER_OFFSET; +use crate::codegen_support::try_handlers::{ + TRY_HANDLER_DIAG_DEPTH_OFFSET, TRY_HANDLER_JMP_BUF_OFFSET, +}; +use crate::codegen_support::{abi, emit::Emitter, platform::Arch}; + +// The firewall builds its handler record by hand and must match the layout the EIR +// try/catch machinery and `__rt_throw_current` assume: next@0, survivor@8, +// diag@TRY_HANDLER_DIAG_DEPTH_OFFSET, jmp_buf@TRY_HANDLER_JMP_BUF_OFFSET. Assert the +// two ABI-critical offsets at compile time so a constant drift breaks the build here +// rather than corrupting a `longjmp` at runtime. +const _: () = assert!(TRY_HANDLER_DIAG_DEPTH_OFFSET == 16); +const _: () = assert!(TRY_HANDLER_JMP_BUF_OFFSET == 24); + +/// Emits `__rt_pdo_call_scalar(descriptor, argv, argc, out)`. +/// +/// Inputs (AArch64): x0 = descriptor, x1 = argv, x2 = argc, x3 = out. No return value; +/// the result is written through `out`. +/// (x86_64): rdi, rsi, rdx, rcx in the same order. +pub fn emit_pdo_call_scalar(emitter: &mut Emitter) { + if emitter.target.arch == Arch::X86_64 { + emit_pdo_call_scalar_linux_x86_64(emitter); + return; + } + + emitter.blank(); + emitter.comment("--- runtime: pdo_call_scalar ---"); + emitter.label_global("__rt_pdo_call_scalar"); + + // Stack frame (304 bytes): + // [sp, #0] = handler record (224 bytes): next@0, survivor@8, diag@16, + // jmp_buf@24 (matches TRY_HANDLER_* / __rt_throw_current). + // [sp, #224] = descriptor [sp, #232] = argv [sp, #240] = argc + // [sp, #248] = out ptr [sp, #256] = args array ptr + // [sp, #264] = boxed args cell [sp, #272] = boxed return [sp, #280] = loop index + // [sp, #288] = saved x29 [sp, #296] = saved x30 + emitter.instruction("sub sp, sp, #304"); // allocate the scalar-adapter frame + emitter.instruction("stp x29, x30, [sp, #288]"); // save frame pointer and return address + emitter.instruction("add x29, sp, #288"); // establish the adapter frame pointer + + emitter.instruction("str x0, [sp, #224]"); // save descriptor pointer + emitter.instruction("str x1, [sp, #232]"); // save argv base pointer + emitter.instruction("str x2, [sp, #240]"); // save argument count + emitter.instruction("str x3, [sp, #248]"); // save the result-out pointer + + // -- fast path: a descriptor without a uniform invoker yields SQL NULL -- + emitter.instruction(&format!("ldr x9, [x0, #{}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the invoker slot + emitter.instruction("cbz x9, __rt_pdo_call_scalar_null_result"); // no invoker → NULL result, nothing to release + + // -- allocate an argc-slot argument array and stamp value_type = Mixed once -- + emitter.instruction("ldr x0, [sp, #240]"); // capacity = argument count (0 is a valid header-only array) + emitter.instruction("mov x1, #8"); // boxed Mixed slots store one pointer each + emitter.instruction("bl __rt_array_new"); // x0 = indexed array backing storage + emitter.instruction("ldr x10, [x0, #-8]"); // load the packed array kind word from the header + emitter.instruction("mov x12, #0x80ff"); // preserve the indexed-array kind and persistent COW flag + emitter.instruction("and x10, x10, x12"); // keep only the persistent metadata bits + emitter.instruction("mov x11, #7"); // value_type tag 7 = boxed Mixed + emitter.instruction("lsl x11, x11, #8"); // move the tag into the packed kind-word byte lane + emitter.instruction("orr x10, x10, x11"); // combine the heap kind with the value_type tag + emitter.instruction("str x10, [x0, #-8]"); // persist the stamped kind word (never re-stamped: no push_int) + emitter.instruction("str x0, [sp, #256]"); // save the args array pointer + + // -- boxing loop: box each ElephcVal arg into a Mixed and store it into the array -- + emitter.instruction("str xzr, [sp, #280]"); // loop index k = 0 + emitter.label("__rt_pdo_call_scalar_loop"); + emitter.instruction("ldr x9, [sp, #280]"); // reload the loop index + emitter.instruction("ldr x10, [sp, #240]"); // reload the argument count + emitter.instruction("cmp x9, x10"); // have all arguments been boxed? + emitter.instruction("b.ge __rt_pdo_call_scalar_loop_done"); // yes → box the container and invoke + emitter.instruction("ldr x11, [sp, #232]"); // reload the argv base pointer + emitter.instruction("mov x12, #40"); // ElephcVal stride in bytes (tag@0,i@8,f@16,ptr@24,len@32) + emitter.instruction("mul x13, x9, x12"); // byte offset of ElephcVal[k] + emitter.instruction("add x11, x11, x13"); // x11 = &argv[k] + emitter.instruction("ldr x14, [x11, #0]"); // load the ElephcVal storage-class tag + emitter.instruction("cmp x14, #1"); // SQLITE_INTEGER? + emitter.instruction("b.eq __rt_pdo_call_scalar_box_int"); + emitter.instruction("cmp x14, #2"); // SQLITE_FLOAT? + emitter.instruction("b.eq __rt_pdo_call_scalar_box_float"); + emitter.instruction("cmp x14, #3"); // SQLITE_TEXT? + emitter.instruction("b.eq __rt_pdo_call_scalar_box_str"); + emitter.instruction("cmp x14, #4"); // SQLITE_BLOB? + emitter.instruction("b.eq __rt_pdo_call_scalar_box_str"); + // -- tag 0 (SQLITE_NULL) or any unexpected code → PHP null (Mixed tag 8) -- + emitter.instruction("mov x0, #8"); // runtime tag 8 = Void/NULL + emitter.instruction("mov x1, #0"); // value_lo unused + emitter.instruction("mov x2, #0"); // value_hi unused + emitter.instruction("b __rt_pdo_call_scalar_box_call"); + // -- SQLITE_INTEGER → Mixed int (tag 0) -- + emitter.label("__rt_pdo_call_scalar_box_int"); + emitter.instruction("mov x0, #0"); // runtime tag 0 = int + emitter.instruction("ldr x1, [x11, #8]"); // value_lo = ElephcVal.i + emitter.instruction("mov x2, #0"); // value_hi unused + emitter.instruction("b __rt_pdo_call_scalar_box_call"); + // -- SQLITE_FLOAT → Mixed float (tag 2); the f64 bit-pattern travels in lo -- + emitter.label("__rt_pdo_call_scalar_box_float"); + emitter.instruction("mov x0, #2"); // runtime tag 2 = float + emitter.instruction("ldr x1, [x11, #16]"); // value_lo = ElephcVal.f raw f64 bit-pattern (integer reg, not FP) + emitter.instruction("mov x2, #0"); // value_hi unused + emitter.instruction("b __rt_pdo_call_scalar_box_call"); + // -- SQLITE_TEXT/BLOB → Mixed string (tag 1); from_value deep-copies the bytes -- + emitter.label("__rt_pdo_call_scalar_box_str"); + emitter.instruction("mov x0, #1"); // runtime tag 1 = string (binary-safe: no separate blob tag) + emitter.instruction("ldr x1, [x11, #24]"); // value_lo = ElephcVal.ptr (byte pointer) + emitter.instruction("ldr x2, [x11, #32]"); // value_hi = ElephcVal.len (explicit byte length) + emitter.instruction("b __rt_pdo_call_scalar_box_call"); + // -- box the (tag, lo, hi) triple and store it into args array slot k -- + emitter.label("__rt_pdo_call_scalar_box_call"); + emitter.instruction("bl __rt_mixed_from_value"); // x0 = owned boxed Mixed argument + emitter.instruction("ldr x9, [sp, #280]"); // reload the loop index (clobbered by the call) + // The args-array reload uses the caller-saved temporary x10 (like the collation + // template's x9), NOT a callee-saved register: this adapter's prologue saves only + // x29/x30, so clobbering an x19-x28 register would corrupt a value the bridge's + // x_scalar caller may be holding live across the call. x9 (loop index) and x12 + // (element offset) are the only other live temporaries here. + emitter.instruction("ldr x10, [sp, #256]"); // reload the args array pointer + emitter.instruction("lsl x12, x9, #3"); // k * 8 (boxed-Mixed slot stride) + emitter.instruction("add x12, x12, #24"); // element region begins 24 bytes past the header + emitter.instruction("str x0, [x10, x12]"); // store the boxed arg into slot k + emitter.instruction("add x9, x9, #1"); // advance the loop index + emitter.instruction("str x9, [sp, #280]"); // persist the loop index + emitter.instruction("str x9, [x10, #0]"); // update the array length field to k+1 + emitter.instruction("b __rt_pdo_call_scalar_loop"); // box the next argument + emitter.label("__rt_pdo_call_scalar_loop_done"); + + // -- box the indexed array as a Mixed cell (tag 4 increfs the array) -- + emitter.instruction("ldr x1, [sp, #256]"); // raw args array pointer → payload lo + emitter.instruction("mov x2, #0"); // payload hi unused for an array + emitter.instruction("mov x0, #4"); // runtime tag 4 = indexed array + emitter.instruction("bl __rt_mixed_from_value"); // x0 = boxed Mixed argument cell + emitter.instruction("str x0, [sp, #264]"); // save the boxed Mixed argument cell + + // -- push a setjmp firewall handler around the invoke -- + // record.next = _exc_handler_top + abi::emit_load_symbol_to_reg(emitter, "x10", "_exc_handler_top", 0); + emitter.instruction("str x10, [sp, #0]"); // handler record: previous handler-stack top + // record.survivor = live _exc_call_frame_top → cleanup stops at this C boundary + abi::emit_load_symbol_to_reg(emitter, "x10", "_exc_call_frame_top", 0); + emitter.instruction("str x10, [sp, #8]"); // handler record: activation frame to survive a throw + // record.diag = _rt_diag_suppression + abi::emit_load_symbol_to_reg(emitter, "x10", "_rt_diag_suppression", 0); + emitter.instruction("str x10, [sp, #16]"); // handler record: saved diagnostic-suppression depth + // _exc_handler_top = &record (record base = sp + 0) + emitter.instruction("mov x10, sp"); // x10 = address of this handler record + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // link the record as the active handler + // setjmp(&jmp_buf) where jmp_buf = record + 24 + emitter.instruction("add x0, sp, #24"); // x0 = &jmp_buf inside the handler record + emitter.bl_c("setjmp"); // returns 0 on first pass, 1 when a throw longjmps back + emitter.instruction("cbnz x0, __rt_pdo_call_scalar_threw"); // nonzero → arrived via longjmp + + // -- normal path: invoke the user function through its descriptor (offset 56) -- + emitter.instruction("ldr x0, [sp, #224]"); // arg0 = descriptor pointer + emitter.instruction("ldr x1, [sp, #264]"); // arg1 = boxed Mixed argument cell + emitter.instruction(&format!("ldr x9, [x0, #{}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the uniform invoker pointer + emitter.instruction("blr x9"); // invoke callable(...args) → OWNED boxed Mixed return in x0 + emitter.instruction("str x0, [sp, #272]"); // save the boxed return for decode + release + + // pop the firewall handler before any further runtime calls + emitter.instruction("ldr x10, [sp, #0]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("ldr x10, [sp, #16]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "x10", "_rt_diag_suppression", 0); // restore it + + // -- type-preserving decode: unbox once and dispatch on the runtime tag -- + emitter.instruction("ldr x0, [sp, #272]"); // boxed return + emitter.instruction("bl __rt_mixed_unbox"); // x0 = tag, x1 = lo, x2 = hi (tag-7 wrappers peeled) + emitter.instruction("cmp x0, #0"); // Mixed int? + emitter.instruction("b.eq __rt_pdo_call_scalar_ret_int"); + emitter.instruction("cmp x0, #2"); // Mixed float? + emitter.instruction("b.eq __rt_pdo_call_scalar_ret_float"); + emitter.instruction("cmp x0, #1"); // Mixed string? + emitter.instruction("b.eq __rt_pdo_call_scalar_ret_string"); + emitter.instruction("cmp x0, #3"); // Mixed bool? + emitter.instruction("b.eq __rt_pdo_call_scalar_ret_bool"); + emitter.instruction("cmp x0, #4"); // Mixed indexed array? + emitter.instruction("b.eq __rt_pdo_call_scalar_ret_array"); + emitter.instruction("cmp x0, #5"); // Mixed associative array? + emitter.instruction("b.eq __rt_pdo_call_scalar_ret_array"); + emitter.instruction("cmp x0, #6"); // Mixed object? + emitter.instruction("b.eq __rt_pdo_call_scalar_ret_object"); + emitter.instruction("cmp x0, #10"); // Mixed callable descriptor? + emitter.instruction("b.eq __rt_pdo_call_scalar_ret_object"); + // -- tag 8 (null) or an unknown tag → SQL NULL -- + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("str xzr, [x11, #0]"); // out.tag = 0 (NULL) + emitter.instruction("b __rt_pdo_call_scalar_release_return"); + emitter.label("__rt_pdo_call_scalar_ret_int"); + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #1"); // ElephcResult tag 1 = INT + emitter.instruction("str x10, [x11, #0]"); // out.tag = 1 + emitter.instruction("str x1, [x11, #8]"); // out.i = lo (int64 value) + emitter.instruction("b __rt_pdo_call_scalar_release_return"); + emitter.label("__rt_pdo_call_scalar_ret_float"); + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #2"); // ElephcResult tag 2 = FLOAT + emitter.instruction("str x10, [x11, #0]"); // out.tag = 2 + emitter.instruction("str x1, [x11, #16]"); // out.f = lo (raw f64 bit-pattern → stored as f64) + emitter.instruction("b __rt_pdo_call_scalar_release_return"); + emitter.label("__rt_pdo_call_scalar_ret_bool"); + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #5"); // ElephcResult tag 5 = BOOL + emitter.instruction("str x10, [x11, #0]"); // out.tag = 5 + emitter.instruction("str x1, [x11, #8]"); // out.i = lo (0/1) + emitter.instruction("b __rt_pdo_call_scalar_release_return"); + emitter.label("__rt_pdo_call_scalar_ret_array"); + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #6"); // ElephcResult tag 6 = unsupported PHP array + emitter.instruction("str x10, [x11, #0]"); // report the array type to the bridge + emitter.instruction("b __rt_pdo_call_scalar_release_return"); + emitter.label("__rt_pdo_call_scalar_ret_object"); + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #7"); // ElephcResult tag 7 = unsupported PHP object/callable + emitter.instruction("str x10, [x11, #0]"); // report the object type to the bridge + emitter.instruction("b __rt_pdo_call_scalar_release_return"); + // -- string: stage the bytes into the bridge BEFORE releasing the owned box -- + emitter.label("__rt_pdo_call_scalar_ret_string"); + emitter.instruction("mov x0, x1"); // stash arg0 = byte pointer (unbox lo) + emitter.instruction("mov x1, x2"); // stash arg1 = byte length (unbox hi) + emitter.instruction("mov x2, #0"); // stash arg2 = is_blob 0 (return text; embedded NULs still preserved by length) + emitter.bl_c("elephc_pdo_udf_stash_bytes"); // deep-copy the string bytes into the bridge's per-thread stash + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #3"); // ElephcResult tag 3 = TEXT (bytes live in the stash) + emitter.instruction("str x10, [x11, #0]"); // out.tag = 3 + emitter.instruction("b __rt_pdo_call_scalar_release_return"); + + // -- release the owned boxed return, then the argument container -- + emitter.label("__rt_pdo_call_scalar_release_return"); + emitter.instruction("ldr x0, [sp, #272]"); // boxed return + emitter.instruction("bl __rt_decref_mixed"); // release the invoker's owned return (bytes already staged) + emitter.instruction("b __rt_pdo_call_scalar_cleanup"); // join the shared container-release path + + // -- longjmp path: the callback threw -- + emitter.label("__rt_pdo_call_scalar_threw"); + emitter.instruction("ldr x10, [sp, #0]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "x10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("ldr x10, [sp, #16]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "x10", "_rt_diag_suppression", 0); // restore it + abi::emit_store_zero_to_symbol(emitter, "_exc_value", 0); // swallow the pending exception (surfaced as a SQL error) + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("mov x10, #-1"); // ElephcResult tag -1 = ERROR (bridge raises sqlite3_result_error) + emitter.instruction("str x10, [x11, #0]"); // out.tag = -1 + + // -- shared cleanup: release the argument container -- + emitter.label("__rt_pdo_call_scalar_cleanup"); + emitter.instruction("ldr x0, [sp, #264]"); // boxed Mixed argument cell + emitter.instruction("bl __rt_decref_mixed"); // release the cell (drops the array ref boxing took) + emitter.instruction("ldr x0, [sp, #256]"); // raw args array pointer + emitter.instruction("bl __rt_decref_any"); // release the array and deep-free its boxed args + emitter.instruction("ldp x29, x30, [sp, #288]"); // restore frame pointer and return address + emitter.instruction("add sp, sp, #304"); // release the adapter frame + emitter.instruction("ret"); // return to the bridge dispatcher (result is in *out) + + // -- fast path: no uniform invoker → NULL result, nothing allocated to release -- + emitter.label("__rt_pdo_call_scalar_null_result"); + emitter.instruction("ldr x11, [sp, #248]"); // out pointer + emitter.instruction("str xzr, [x11, #0]"); // out.tag = 0 (NULL) + emitter.instruction("ldp x29, x30, [sp, #288]"); // restore frame pointer and return address + emitter.instruction("add sp, sp, #304"); // release the adapter frame + emitter.instruction("ret"); // return to the bridge dispatcher +} + +/// x86_64 implementation of `__rt_pdo_call_scalar`. +fn emit_pdo_call_scalar_linux_x86_64(emitter: &mut Emitter) { + emitter.blank(); + emitter.comment("--- runtime: pdo_call_scalar ---"); + emitter.label_global("__rt_pdo_call_scalar"); + + // Frame (288 bytes below rbp): + // [rbp-8] descriptor [rbp-16] argv [rbp-24] argc [rbp-32] out ptr + // [rbp-40] args array [rbp-48] boxed args cell [rbp-56] boxed return + // [rbp-64] loop index + // [rbp-288] handler record (224 bytes): next@0, survivor@8, diag@16, jmp_buf@24 + // → record.next=[rbp-288], survivor=[rbp-280], diag=[rbp-272], + // jmp_buf base=[rbp-264]. + // push rbp + sub rsp,288 keeps rsp 16-aligned for the nested calls. + emitter.instruction("push rbp"); // preserve the caller frame pointer + emitter.instruction("mov rbp, rsp"); // establish the adapter frame pointer + emitter.instruction("sub rsp, 288"); // reserve the slots and the 224-byte handler record + + emitter.instruction("mov QWORD PTR [rbp - 8], rdi"); // save descriptor pointer + emitter.instruction("mov QWORD PTR [rbp - 16], rsi"); // save argv base pointer + emitter.instruction("mov QWORD PTR [rbp - 24], rdx"); // save argument count + emitter.instruction("mov QWORD PTR [rbp - 32], rcx"); // save the result-out pointer + + // -- fast path: a descriptor without a uniform invoker yields SQL NULL -- + emitter.instruction(&format!("mov r10, QWORD PTR [rdi + {}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the invoker slot + emitter.instruction("test r10, r10"); // does the descriptor expose a uniform invoker? + emitter.instruction("jz __rt_pdo_call_scalar_null_result_x86"); // no invoker → NULL result, nothing to release + + // -- allocate an argc-slot argument array and stamp value_type = Mixed once -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 24]"); // capacity = argument count (0 is a valid header-only array) + emitter.instruction("mov rsi, 8"); // boxed Mixed slots store one pointer each + emitter.instruction("call __rt_array_new"); // rax = indexed array backing storage + emitter.instruction("mov r10, QWORD PTR [rax - 8]"); // load the packed array kind word from the header + emitter.instruction("mov r11, 0xffffffff000080ff"); // preserve heap marker + indexed-array kind + COW bit + emitter.instruction("and r10, r11"); // keep only the persistent metadata bits + emitter.instruction("mov r11, 7"); // value_type tag 7 = boxed Mixed + emitter.instruction("shl r11, 8"); // move the tag into the packed kind-word byte lane + emitter.instruction("or r10, r11"); // combine the heap kind with the value_type tag + emitter.instruction("mov QWORD PTR [rax - 8], r10"); // persist the stamped kind word (never re-stamped: no push_int) + emitter.instruction("mov QWORD PTR [rbp - 40], rax"); // save the args array pointer + + // -- boxing loop: box each ElephcVal arg into a Mixed and store it into the array -- + emitter.instruction("mov QWORD PTR [rbp - 64], 0"); // loop index k = 0 + emitter.label("__rt_pdo_call_scalar_loop_x86"); + emitter.instruction("mov r9, QWORD PTR [rbp - 64]"); // reload the loop index + emitter.instruction("mov r10, QWORD PTR [rbp - 24]"); // reload the argument count + emitter.instruction("cmp r9, r10"); // have all arguments been boxed? + emitter.instruction("jge __rt_pdo_call_scalar_loop_done_x86"); // yes → box the container and invoke + emitter.instruction("mov r11, QWORD PTR [rbp - 16]"); // reload the argv base pointer + emitter.instruction("imul rax, r9, 40"); // byte offset of ElephcVal[k] (40-byte stride) + emitter.instruction("add r11, rax"); // r11 = &argv[k] + emitter.instruction("mov r8, QWORD PTR [r11 + 0]"); // load the ElephcVal storage-class tag + emitter.instruction("cmp r8, 1"); // SQLITE_INTEGER? + emitter.instruction("je __rt_pdo_call_scalar_box_int_x86"); + emitter.instruction("cmp r8, 2"); // SQLITE_FLOAT? + emitter.instruction("je __rt_pdo_call_scalar_box_float_x86"); + emitter.instruction("cmp r8, 3"); // SQLITE_TEXT? + emitter.instruction("je __rt_pdo_call_scalar_box_str_x86"); + emitter.instruction("cmp r8, 4"); // SQLITE_BLOB? + emitter.instruction("je __rt_pdo_call_scalar_box_str_x86"); + // -- tag 0 (SQLITE_NULL) or any unexpected code → PHP null (Mixed tag 8) -- + emitter.instruction("mov eax, 8"); // runtime tag 8 = Void/NULL + emitter.instruction("xor edi, edi"); // value_lo unused + emitter.instruction("xor esi, esi"); // value_hi unused + emitter.instruction("jmp __rt_pdo_call_scalar_box_call_x86"); + // -- SQLITE_INTEGER → Mixed int (tag 0) -- + emitter.label("__rt_pdo_call_scalar_box_int_x86"); + emitter.instruction("mov eax, 0"); // runtime tag 0 = int + emitter.instruction("mov rdi, QWORD PTR [r11 + 8]"); // value_lo = ElephcVal.i + emitter.instruction("xor esi, esi"); // value_hi unused + emitter.instruction("jmp __rt_pdo_call_scalar_box_call_x86"); + // -- SQLITE_FLOAT → Mixed float (tag 2); the f64 bit-pattern travels in lo -- + emitter.label("__rt_pdo_call_scalar_box_float_x86"); + emitter.instruction("mov eax, 2"); // runtime tag 2 = float + emitter.instruction("mov rdi, QWORD PTR [r11 + 16]"); // value_lo = ElephcVal.f raw f64 bit-pattern (integer reg, not xmm) + emitter.instruction("xor esi, esi"); // value_hi unused + emitter.instruction("jmp __rt_pdo_call_scalar_box_call_x86"); + // -- SQLITE_TEXT/BLOB → Mixed string (tag 1); from_value deep-copies the bytes -- + emitter.label("__rt_pdo_call_scalar_box_str_x86"); + emitter.instruction("mov eax, 1"); // runtime tag 1 = string (binary-safe: no separate blob tag) + emitter.instruction("mov rdi, QWORD PTR [r11 + 24]"); // value_lo = ElephcVal.ptr (byte pointer) + emitter.instruction("mov rsi, QWORD PTR [r11 + 32]"); // value_hi = ElephcVal.len (explicit byte length) + emitter.instruction("jmp __rt_pdo_call_scalar_box_call_x86"); + // -- box the (tag, lo, hi) triple and store it into args array slot k -- + emitter.label("__rt_pdo_call_scalar_box_call_x86"); + emitter.instruction("call __rt_mixed_from_value"); // rax = owned boxed Mixed argument + emitter.instruction("mov r9, QWORD PTR [rbp - 64]"); // reload the loop index (clobbered by the call) + emitter.instruction("mov r11, QWORD PTR [rbp - 40]"); // reload the args array pointer + emitter.instruction("mov rcx, r9"); // compute the element byte offset + emitter.instruction("shl rcx, 3"); // k * 8 (boxed-Mixed slot stride) + emitter.instruction("add rcx, 24"); // element region begins 24 bytes past the header + emitter.instruction("mov QWORD PTR [r11 + rcx], rax"); // store the boxed arg into slot k + emitter.instruction("add r9, 1"); // advance the loop index + emitter.instruction("mov QWORD PTR [rbp - 64], r9"); // persist the loop index + emitter.instruction("mov QWORD PTR [r11], r9"); // update the array length field to k+1 + emitter.instruction("jmp __rt_pdo_call_scalar_loop_x86"); // box the next argument + emitter.label("__rt_pdo_call_scalar_loop_done_x86"); + + // -- box the indexed array as a Mixed cell (tag 4 increfs the array) -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 40]"); // raw args array pointer → payload lo + emitter.instruction("xor esi, esi"); // payload hi unused for an array + emitter.instruction("mov eax, 4"); // runtime tag 4 = indexed array + emitter.instruction("call __rt_mixed_from_value"); // rax = boxed Mixed argument cell + emitter.instruction("mov QWORD PTR [rbp - 48], rax"); // save the boxed Mixed argument cell + + // -- push a setjmp firewall handler around the invoke -- + abi::emit_load_symbol_to_reg(emitter, "r10", "_exc_handler_top", 0); // previous handler-stack top + emitter.instruction("mov QWORD PTR [rbp - 288], r10"); // handler record: record.next + abi::emit_load_symbol_to_reg(emitter, "r10", "_exc_call_frame_top", 0); // live activation-frame top + emitter.instruction("mov QWORD PTR [rbp - 280], r10"); // handler record: survivor frame (cleanup stops here) + abi::emit_load_symbol_to_reg(emitter, "r10", "_rt_diag_suppression", 0); // current diagnostic-suppression depth + emitter.instruction("mov QWORD PTR [rbp - 272], r10"); // handler record: saved diagnostic depth + emitter.instruction("lea r10, [rbp - 288]"); // r10 = address of this handler record + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // link the record as the active handler + emitter.instruction("lea rdi, [rbp - 264]"); // rdi = &jmp_buf inside the handler record (record + 24) + emitter.bl_c("setjmp"); // returns 0 on first pass, 1 when a throw longjmps back + emitter.instruction("test rax, rax"); // did control arrive via longjmp? + emitter.instruction("jne __rt_pdo_call_scalar_threw_x86"); // nonzero → arrived via longjmp + + // -- normal path: invoke the user function through its descriptor (offset 56) -- + emitter.instruction("mov rdi, QWORD PTR [rbp - 8]"); // arg0 = descriptor pointer + emitter.instruction("mov rsi, QWORD PTR [rbp - 48]"); // arg1 = boxed Mixed argument cell + emitter.instruction(&format!("mov r10, QWORD PTR [rdi + {}]", CALLABLE_DESC_INVOKER_OFFSET)); // load the uniform invoker pointer + emitter.instruction("call r10"); // invoke callable(...args) → OWNED boxed Mixed return in rax + emitter.instruction("mov QWORD PTR [rbp - 56], rax"); // save the boxed return for decode + release + + // pop the firewall handler before any further runtime calls + emitter.instruction("mov r10, QWORD PTR [rbp - 288]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("mov r10, QWORD PTR [rbp - 272]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "r10", "_rt_diag_suppression", 0); // restore it + + // -- type-preserving decode: unbox once and dispatch on the runtime tag -- + emitter.instruction("mov rax, QWORD PTR [rbp - 56]"); // boxed return (unbox reads RAX) + emitter.instruction("call __rt_mixed_unbox"); // rax = tag, rdi = lo, rdx = hi (tag-7 wrappers peeled) + emitter.instruction("cmp rax, 0"); // Mixed int? + emitter.instruction("je __rt_pdo_call_scalar_ret_int_x86"); + emitter.instruction("cmp rax, 2"); // Mixed float? + emitter.instruction("je __rt_pdo_call_scalar_ret_float_x86"); + emitter.instruction("cmp rax, 1"); // Mixed string? + emitter.instruction("je __rt_pdo_call_scalar_ret_string_x86"); + emitter.instruction("cmp rax, 3"); // Mixed bool? + emitter.instruction("je __rt_pdo_call_scalar_ret_bool_x86"); + emitter.instruction("cmp rax, 4"); // Mixed indexed array? + emitter.instruction("je __rt_pdo_call_scalar_ret_array_x86"); + emitter.instruction("cmp rax, 5"); // Mixed associative array? + emitter.instruction("je __rt_pdo_call_scalar_ret_array_x86"); + emitter.instruction("cmp rax, 6"); // Mixed object? + emitter.instruction("je __rt_pdo_call_scalar_ret_object_x86"); + emitter.instruction("cmp rax, 10"); // Mixed callable descriptor? + emitter.instruction("je __rt_pdo_call_scalar_ret_object_x86"); + // -- tag 8 (null) or an unknown tag → SQL NULL -- + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 0"); // out.tag = 0 (NULL) + emitter.instruction("jmp __rt_pdo_call_scalar_release_return_x86"); + emitter.label("__rt_pdo_call_scalar_ret_int_x86"); + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 1"); // out.tag = 1 (INT) + emitter.instruction("mov QWORD PTR [r11 + 8], rdi"); // out.i = lo (int64 value) + emitter.instruction("jmp __rt_pdo_call_scalar_release_return_x86"); + emitter.label("__rt_pdo_call_scalar_ret_float_x86"); + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 2"); // out.tag = 2 (FLOAT) + emitter.instruction("mov QWORD PTR [r11 + 16], rdi"); // out.f = lo (raw f64 bit-pattern → stored as f64) + emitter.instruction("jmp __rt_pdo_call_scalar_release_return_x86"); + emitter.label("__rt_pdo_call_scalar_ret_bool_x86"); + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 5"); // out.tag = 5 (BOOL) + emitter.instruction("mov QWORD PTR [r11 + 8], rdi"); // out.i = lo (0/1) + emitter.instruction("jmp __rt_pdo_call_scalar_release_return_x86"); + emitter.label("__rt_pdo_call_scalar_ret_array_x86"); + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 6"); // out.tag = unsupported PHP array + emitter.instruction("jmp __rt_pdo_call_scalar_release_return_x86"); + emitter.label("__rt_pdo_call_scalar_ret_object_x86"); + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 7"); // out.tag = unsupported PHP object/callable + emitter.instruction("jmp __rt_pdo_call_scalar_release_return_x86"); + // -- string: stage the bytes into the bridge BEFORE releasing the owned box -- + emitter.label("__rt_pdo_call_scalar_ret_string_x86"); + emitter.instruction("mov rsi, rdx"); // stash arg1 = byte length (unbox hi), before rdx is reused + emitter.instruction("xor edx, edx"); // stash arg2 = is_blob 0 (return text; embedded NULs preserved by length) + // rdi already holds the unbox lo (byte pointer) = stash arg0. + emitter.bl_c("elephc_pdo_udf_stash_bytes"); // deep-copy the string bytes into the bridge's per-thread stash + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 3"); // out.tag = 3 (TEXT; bytes live in the stash) + emitter.instruction("jmp __rt_pdo_call_scalar_release_return_x86"); + + // -- release the owned boxed return, then the argument container -- + emitter.label("__rt_pdo_call_scalar_release_return_x86"); + emitter.instruction("mov rax, QWORD PTR [rbp - 56]"); // boxed return + emitter.instruction("call __rt_decref_mixed"); // release the invoker's owned return (bytes already staged) + emitter.instruction("jmp __rt_pdo_call_scalar_cleanup_x86"); // join the shared container-release path + + // -- longjmp path: the callback threw -- + emitter.label("__rt_pdo_call_scalar_threw_x86"); + emitter.instruction("mov r10, QWORD PTR [rbp - 288]"); // record.next + abi::emit_store_reg_to_symbol(emitter, "r10", "_exc_handler_top", 0); // unlink the handler record + emitter.instruction("mov r10, QWORD PTR [rbp - 272]"); // saved diagnostic-suppression depth + abi::emit_store_reg_to_symbol(emitter, "r10", "_rt_diag_suppression", 0); // restore it + abi::emit_store_zero_to_symbol(emitter, "_exc_value", 0); // swallow the pending exception (surfaced as a SQL error) + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], -1"); // out.tag = -1 (ERROR; bridge raises sqlite3_result_error) + + // -- shared cleanup: release the argument container -- + emitter.label("__rt_pdo_call_scalar_cleanup_x86"); + emitter.instruction("mov rax, QWORD PTR [rbp - 48]"); // boxed Mixed argument cell + emitter.instruction("call __rt_decref_mixed"); // release the cell (drops the array ref boxing took) + emitter.instruction("mov rax, QWORD PTR [rbp - 40]"); // raw args array pointer + emitter.instruction("call __rt_decref_any"); // release the array and deep-free its boxed args + emitter.instruction("add rsp, 288"); // release the adapter frame + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return to the bridge dispatcher (result is in *out) + + // -- fast path: no uniform invoker → NULL result, nothing allocated to release -- + emitter.label("__rt_pdo_call_scalar_null_result_x86"); + emitter.instruction("mov r11, QWORD PTR [rbp - 32]"); // out pointer + emitter.instruction("mov QWORD PTR [r11], 0"); // out.tag = 0 (NULL) + emitter.instruction("add rsp, 288"); // release the adapter frame + emitter.instruction("pop rbp"); // restore the caller frame pointer + emitter.instruction("ret"); // return to the bridge dispatcher +} diff --git a/src/codegen_support/runtime/pointers/ptr_check_nonnull.rs b/src/codegen_support/runtime/pointers/ptr_check_nonnull.rs index 519f97361f..f5aadcf6c8 100644 --- a/src/codegen_support/runtime/pointers/ptr_check_nonnull.rs +++ b/src/codegen_support/runtime/pointers/ptr_check_nonnull.rs @@ -67,7 +67,7 @@ fn emit_ptr_check_nonnull_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov eax, 1"); // Linux x86_64 syscall number 1 = write emitter.instruction("syscall"); // emit the fatal null-dereference message before terminating the process emitter.instruction("mov edi, 1"); // return process exit code 1 for the fatal null-dereference abort path - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall number 60 = exit + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 = exit_group emitter.instruction("syscall"); // terminate the process after reporting the fatal null-dereference // -- success path -- diff --git a/src/codegen_support/runtime/pointers/ptr_read_string.rs b/src/codegen_support/runtime/pointers/ptr_read_string.rs index 4402aff0f5..546c5a7532 100644 --- a/src/codegen_support/runtime/pointers/ptr_read_string.rs +++ b/src/codegen_support/runtime/pointers/ptr_read_string.rs @@ -145,6 +145,6 @@ fn emit_ptr_read_string_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov eax, 1"); // Linux x86_64 syscall number 1 = write emitter.instruction("syscall"); // emit the fatal negative-length message before terminating the process emitter.instruction("mov edi, 1"); // return process exit code 1 for the fatal abort path - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall number 60 = exit + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 = exit_group emitter.instruction("syscall"); // terminate the process after reporting the fatal length error } diff --git a/src/codegen_support/runtime/strings/str_repeat.rs b/src/codegen_support/runtime/strings/str_repeat.rs index b9cc5e5ca2..415b52a79c 100644 --- a/src/codegen_support/runtime/strings/str_repeat.rs +++ b/src/codegen_support/runtime/strings/str_repeat.rs @@ -243,6 +243,6 @@ fn emit_str_repeat_linux_x86_64(emitter: &mut Emitter) { emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 = write emitter.instruction("syscall"); // emit the fatal negative-repeat message before terminating emitter.instruction("mov edi, 1"); // exit code 1 for the negative-repeat abort path - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall 60 = exit + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 = exit_group emitter.instruction("syscall"); // terminate the process after reporting the invalid repeat count } diff --git a/src/codegen_support/runtime/system/match_unhandled.rs b/src/codegen_support/runtime/system/match_unhandled.rs index 73fec5705c..286b1c91ee 100644 --- a/src/codegen_support/runtime/system/match_unhandled.rs +++ b/src/codegen_support/runtime/system/match_unhandled.rs @@ -16,8 +16,8 @@ use crate::codegen_support::{abi, emit::Emitter, platform::Arch}; /// process with exit code 70 (EX_SOFTWARE). It is invoked by generated code when a /// match expression has no corresponding arm for a given discriminant value. /// -/// AArch64 path: uses syscall 4 (sys_write) then syscall 1 (sys_exit). -/// x86_64 path: uses syscall 1 (write) then syscall 60 (exit). +/// AArch64 path: uses syscall 4 (write), then `sys_exit` on macOS or `exit_group` on Linux. +/// x86_64 path: uses syscall 1 (write), then syscall 231 (`exit_group`). pub fn emit_match_unhandled(emitter: &mut Emitter) { emitter.blank(); emitter.comment("--- runtime: match_unhandled ---"); @@ -38,7 +38,7 @@ pub fn emit_match_unhandled(emitter: &mut Emitter) { emitter.instruction("mov eax, 1"); // Linux x86_64 syscall number 1 = write emitter.instruction("syscall"); // emit the unhandled-match fatal diagnostic on x86_64 emitter.instruction("mov edi, 70"); // use EX_SOFTWARE as the process exit status on the x86_64 fatal path - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall number 60 = exit + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 = exit_group emitter.instruction("syscall"); // terminate the process after reporting the unhandled match case } } diff --git a/src/codegen_support/runtime/system/php_uname.rs b/src/codegen_support/runtime/system/php_uname.rs index 30f57e4688..61ea66afae 100644 --- a/src/codegen_support/runtime/system/php_uname.rs +++ b/src/codegen_support/runtime/system/php_uname.rs @@ -412,6 +412,6 @@ fn emit_x86_64_fail(emitter: &mut Emitter, label: &str, msg_label: &str, msg_len emitter.instruction("mov eax, 1"); // Linux x86_64 syscall 1 writes the diagnostic bytes emitter.instruction("syscall"); // emit the invalid php_uname mode diagnostic emitter.instruction("mov edi, 1"); // use a failing process exit status for invalid php_uname modes - emitter.instruction("mov eax, 60"); // Linux x86_64 syscall 60 exits the process + emitter.instruction("mov eax, 231"); // Linux x86_64 syscall 231 exits the process group emitter.instruction("syscall"); // terminate after the fatal php_uname diagnostic } diff --git a/src/codegen_support/runtime_features.rs b/src/codegen_support/runtime_features.rs index c6ff02c9e7..a3dd329735 100644 --- a/src/codegen_support/runtime_features.rs +++ b/src/codegen_support/runtime_features.rs @@ -61,6 +61,12 @@ pub struct RuntimeFeatures { /// tail-call `elephc_web_write` (a symbol only linked into `--web` binaries). /// Non-web runtimes must leave this false so they never reference that symbol. pub web: bool, + /// True when the program lowers a `__elephc_pdo_adapter_addr` call (the PDO + /// Tier-D prelude decomposing a callback into descriptor + adapter pointers), + /// which takes the address of a `__rt_pdo_*` adapter. Emitting the adapter under + /// this bit keeps the address-of reference resolvable without pulling the body + /// into non-PDO programs. + pub pdo_udf: bool, } impl RuntimeFeatures { @@ -74,6 +80,7 @@ impl RuntimeFeatures { eval_bridge: false, eval_scope: false, web: false, + pdo_udf: false, } } @@ -88,6 +95,7 @@ impl RuntimeFeatures { eval_bridge: true, eval_scope: true, web: true, + pdo_udf: true, } } } @@ -1186,6 +1194,7 @@ mod tests { eval_bridge: false, eval_scope: false, web: false, + pdo_udf: false, }) .iter() .any(|requirement| requirement == &LinkRequirement::Bridge("elephc_crypto"))); diff --git a/src/ir/builder.rs b/src/ir/builder.rs index c72f149e5c..d358945218 100644 --- a/src/ir/builder.rs +++ b/src/ir/builder.rs @@ -248,6 +248,32 @@ impl<'f> Builder<'f> { self.current } + /// Clones the entire function body so a speculative lowering can be undone exactly. + /// + /// A length-truncation rollback would be unsound: the function tables are append-only for + /// *new* entries, but lowering also MUTATES existing ones — `widen_local_storage_type` + /// rewrites a `LocalSlot`'s `php_type`/`ir_type` in place, and `terminate` writes a + /// terminator into a block that already existed. Only a wholesale clone restores both the + /// tables' lengths and the contents of the entries that survived. + pub fn snapshot_function(&self) -> Function { + self.func.clone() + } + + /// Restores a function body captured by `snapshot_function`, discarding everything emitted + /// since. The insertion cursor is restored separately by `restore_insertion_cursor`, because + /// a caller may want to resume at a different block than the one that was current. + pub fn restore_function(&mut self, function: Function) { + *self.func = function; + } + + /// Restores the insertion cursor recorded alongside a function snapshot. + pub fn restore_insertion_cursor(&mut self, block: Option) { + if let Some(block) = block { + self.assert_block_exists(block); + } + self.current = block; + } + /// Returns true when the selected block already has a terminator. pub fn insertion_block_is_terminated(&self) -> bool { self.current diff --git a/src/ir/function.rs b/src/ir/function.rs index 485a684fed..34c3c49856 100644 --- a/src/ir/function.rs +++ b/src/ir/function.rs @@ -67,6 +67,15 @@ pub struct Function { pub attribute_args: Vec>>, pub generator_source: Option, pub flags: FunctionFlags, + /// Slots the epilogue must never release: values this frame BORROWS rather than owns. + /// + /// The inliner transplants a callee's parameter slots (and its directly-returned slots) + /// into the host, where they hold a +0 borrow the host never acquired. It used to signal + /// that by remapping their `LocalKind` to `HiddenTemp` — but `local_kind_needs_epilogue_cleanup` + /// sweeps `HiddenTemp` too, so the exclusion was a no-op and the host released a reference it + /// did not own. A read-only `array` parameter called in a loop therefore died with + /// `heap debug detected bad refcount` under `-O`, while `-O0` was clean. + pub no_epilogue_cleanup_slots: std::collections::HashSet, } impl Function { @@ -89,6 +98,7 @@ impl Function { attribute_args: Vec::new(), generator_source: None, flags: FunctionFlags::default(), + no_epilogue_cleanup_slots: std::collections::HashSet::new(), } } diff --git a/src/ir/instr.rs b/src/ir/instr.rs index dd4a7ec480..841a122eea 100644 --- a/src/ir/instr.rs +++ b/src/ir/instr.rs @@ -316,6 +316,8 @@ pub enum Op { ResourceToStr, Cast, MixedBox, + /// Copies a boxed Mixed zval cell while retaining its nested payload for value semantics. + MixedClone, InvokerRefArg, MixedUnbox, MixedTagOf, @@ -338,16 +340,32 @@ pub enum Op { HashLen, ArrayGet, ArrayGetSilent, + /// Prepares an indexed element for mutation: boxed Mixed reads retain the owning cell, while + /// typed container reads copy-on-write separate and republish the child in its parent slot. ArrayGetForWrite, HashGet, - HashGetForWrite, HashGetSilent, + /// Prepares an associative element for mutation: boxed Mixed reads retain the owning cell, + /// while typed container reads copy-on-write separate and republish the child in its entry. + HashGetForWrite, ArrayIsset, HashIsset, ArrayElemAddr, ArraySet, HashSet, HashUnset, + /// Writes PHP null into `container[key]`, releasing whatever was there. + /// + /// Used by the nested-append lowering to hand a bucket's *only other* reference over to + /// the temporary that is about to be appended to: after the read the bucket is owned by + /// both the slot and the temp (refcount 2), which would make the append copy-on-write + /// clone it — O(length) on every push, hence O(n^2) over a growing bucket. Nulling the + /// slot drops it back to 1, so the append mutates in place, and the write-back then + /// re-publishes the bucket into the very same slot. + /// + /// It can never free the bucket: it only ever runs *after* the read has taken its + /// reference, so the refcount it decrements is at least 2. + SlotDetach, ArrayPush, MixedArrayAppend, HashAppend, @@ -382,6 +400,22 @@ pub enum Op { DynamicObjectNew, DynamicObjectNewMixed, DynamicObjectNewWithoutConstructorMixed, + /// Reinterprets one runtime callable descriptor as an opaque bridge pointer. + CallablePtr, + /// Normalizes any supported PHP callable form into an owned descriptor. + NormalizeCallable, + /// Returns the address of one compiler-emitted PDO callback adapter. + PdoAdapterAddr, + /// Reports whether an AOT class selected by runtime name has a constructor. + DynamicClassHasConstructor, + /// Classifies a runtime class name for PDO statement construction. + DynamicPdoStatementClassStatus, + /// Classifies a runtime late-static class name for `PDO::connect()`. + DynamicPdoCalledClassStatus, + /// Invokes a PDO statement subclass constructor from a boxed argument container. + DynamicPdoStatementConstructorCall, + /// Initializes the private base state of a PDO statement subclass. + DynamicPdoStatementInitialize, PropGet, PropInitialized, PropSet, @@ -438,6 +472,8 @@ pub enum Op { EvalConstantExists, EvalConstantFetch, RuntimeCall, + /// Reads through a boxed Mixed/ArrayAccess receiver for an imminent nested write. + MixedArrayGetForWrite, ExternCall, ClosureNew, ClosureCapture, @@ -543,6 +579,11 @@ impl Op { | FunctionVariantDispatch | PtrCast | PtrOffset + | CallablePtr + | PdoAdapterAddr + | DynamicClassHasConstructor + | DynamicPdoStatementClassStatus + | DynamicPdoCalledClassStatus | Move | Borrow | Nop => E::PURE, @@ -587,8 +628,9 @@ impl Op { ConcatReset => E::WRITES_GLOBAL, Cast => E::READS_HEAP | E::ALLOC_CONCAT | E::MAY_WARN | E::MAY_FATAL, InvokerRefArg => E::READS_LOCAL | E::ALLOC_HEAP, - MixedBox | ArrayToMixed | HashToMixed | ArrayNew | HashNew | ObjectNew - | ClosureNew | FirstClassCallableNew | CallableArrayNew | BufferNew | GeneratorNew => { + MixedBox | MixedClone | ArrayToMixed | HashToMixed | ArrayNew | HashNew | ObjectNew + | ClosureNew | FirstClassCallableNew | CallableArrayNew | NormalizeCallable | BufferNew + | GeneratorNew => { E::ALLOC_HEAP } IsNull | IsTruthy | TypePredicate | MixedUnbox | MixedCastBool | MixedCastInt @@ -603,7 +645,7 @@ impl Op { // reorderable or redundant against the plain reads around it. ArrayGetForWrite | HashGetForWrite => { E::READS_HEAP | E::WRITES_HEAP | E::WRITES_LOCAL | E::ALLOC_HEAP - | E::REFCOUNT_OP | E::MAY_WARN + | E::REFCOUNT_OP | E::MAY_WARN | E::MAY_FATAL } StrPersist | ArrayEnsureUnique | HashEnsureUnique | ArrayCloneShallow | HashCloneShallow | ObjectCloneShallow => { @@ -625,6 +667,10 @@ impl Op { | DynamicPropSet | BufferSet | BufferFree | PackedFieldSet | PtrWrite | PtrWriteString => E::WRITES_HEAP | E::MAY_FATAL | E::REFCOUNT_OP, MixedArrayAppend => E::READS_HEAP | E::WRITES_HEAP | E::ALLOC_HEAP | E::MAY_FATAL | E::REFCOUNT_OP, + // ALLOC_HEAP because the hash-storage lowering goes through `__rt_hash_set`, which + // checks its load factor and may grow/rehash the table before it even knows whether + // the key is already present. + SlotDetach => E::READS_HEAP | E::WRITES_HEAP | E::ALLOC_HEAP | E::MAY_FATAL | E::REFCOUNT_OP, ArrayElemAddr | ArraySetMixedKey => { E::READS_HEAP | E::WRITES_HEAP | E::ALLOC_HEAP | E::MAY_FATAL | E::REFCOUNT_OP } @@ -666,6 +712,9 @@ impl Op { | EvalObjectNew | EvalStaticMethodCall | RuntimeCall + | MixedArrayGetForWrite + | DynamicPdoStatementConstructorCall + | DynamicPdoStatementInitialize | ClosureCall | ExprCall | CallableDescriptorInvoke @@ -804,6 +853,7 @@ impl Op { ResourceToStr => "resource_to_str", Cast => "cast", MixedBox => "mixed_box", + MixedClone => "mixed_clone", InvokerRefArg => "invoker_ref_arg", MixedUnbox => "mixed_unbox", MixedTagOf => "mixed_tag_of", @@ -828,14 +878,15 @@ impl Op { ArrayGetSilent => "array_get_silent", ArrayGetForWrite => "array_get_for_write", HashGet => "hash_get", - HashGetForWrite => "hash_get_for_write", HashGetSilent => "hash_get_silent", + HashGetForWrite => "hash_get_for_write", ArrayIsset => "array_isset", HashIsset => "hash_isset", ArrayElemAddr => "array_elem_addr", ArraySet => "array_set", HashSet => "hash_set", HashUnset => "hash_unset", + SlotDetach => "slot_detach", ArrayPush => "array_push", MixedArrayAppend => "mixed_array_append", HashAppend => "hash_append", @@ -872,6 +923,14 @@ impl Op { DynamicObjectNewWithoutConstructorMixed => { "dynamic_object_new_without_constructor_mixed" } + CallablePtr => "callable_ptr", + NormalizeCallable => "normalize_callable", + PdoAdapterAddr => "pdo_adapter_addr", + DynamicClassHasConstructor => "dynamic_class_has_constructor", + DynamicPdoStatementClassStatus => "dynamic_pdo_statement_class_status", + DynamicPdoCalledClassStatus => "dynamic_pdo_called_class_status", + DynamicPdoStatementConstructorCall => "dynamic_pdo_statement_constructor_call", + DynamicPdoStatementInitialize => "dynamic_pdo_statement_initialize", PropGet => "prop_get", PropInitialized => "prop_initialized", PropSet => "prop_set", @@ -908,6 +967,7 @@ impl Op { EvalConstantExists => "eval_constant_exists", EvalConstantFetch => "eval_constant_fetch", RuntimeCall => "runtime_call", + MixedArrayGetForWrite => "mixed_array_get_for_write", ExternCall => "extern_call", ClosureNew => "closure_new", ClosureCapture => "closure_capture", diff --git a/src/ir/validator.rs b/src/ir/validator.rs index aa0ce7562a..1ef509ee66 100644 --- a/src/ir/validator.rs +++ b/src/ir/validator.rs @@ -537,6 +537,27 @@ fn validate_opcode_rules( | HashCloneShallow => { check_first_heap(function, inst_id, inst, IrHeapKind::Hash, "Heap(Hash)") } + // `SlotDetach` is the one array op that accepts either storage: it nulls `container[key]` + // on an indexed array (via `__rt_array_set_refcounted`) or on a hash (via `__rt_hash_set`). + // This rule is not optional — the match ends in `_ => Ok(())`, so an unlisted op would be + // validated by *accepting anything*, including a malformed operand list. + SlotDetach => { + check_count(inst_id, inst, 2, "2")?; + let container = inst.operands[0]; + let actual = function + .value(container) + .ok_or(ValidationError::UnknownValue(container))? + .ir_type; + match actual { + IrType::Heap(IrHeapKind::Array) | IrType::Heap(IrHeapKind::Hash) => Ok(()), + _ => Err(ValidationError::OperandTypeMismatch { + inst: inst_id, + operand: container, + expected: "Heap(Array) or Heap(Hash)", + actual, + }), + } + } IterCurrentValueRef => check_count(inst_id, inst, 1, "1"), ArrayKeyExists | OffsetExists => check_count_at_least(inst_id, inst, 1, "at least 1"), BufferLen | BufferGet | BufferSet | BufferFree => { @@ -557,6 +578,14 @@ fn validate_opcode_rules( | InstanceOfDynamic => { check_count_at_least(inst_id, inst, 1, "at least 1") } + CallablePtr + | NormalizeCallable + | PdoAdapterAddr + | DynamicClassHasConstructor + | DynamicPdoStatementClassStatus + | DynamicPdoCalledClassStatus => check_count(inst_id, inst, 1, "1"), + DynamicPdoStatementConstructorCall => check_count(inst_id, inst, 3, "3"), + DynamicPdoStatementInitialize => check_count(inst_id, inst, 5, "5"), RuntimeCall => validate_typed_runtime_call(function, inst_id, inst), _ => Ok(()), } diff --git a/src/ir_lower/context.rs b/src/ir_lower/context.rs index b8c01ffa67..87c57dcdd1 100644 --- a/src/ir_lower/context.rs +++ b/src/ir_lower/context.rs @@ -21,7 +21,8 @@ use crate::names::{php_symbol_key, property_hook_get_method, property_hook_set_m use crate::parser::ast::{Expr, ExprKind, StaticReceiver, Stmt, TypeExpr}; use crate::span::Span; use crate::types::{ - ClassInfo, EnumInfo, ExternFunctionSig, FunctionSig, InterfaceInfo, PackedClassInfo, PhpType, + array_storage_conversion, join_array_storage_conversion, ClassInfo, EnumInfo, + ExternFunctionSig, FunctionSig, InterfaceInfo, PackedClassInfo, PhpType, ReturnAliasSummaries, ThrowAccessInfo, TypeEnv, }; @@ -93,6 +94,79 @@ pub(crate) struct ClosureCapture { pub value: ValueId, } +/// Rollback point for a speculative statement lowering. +pub(crate) struct LoweringSnapshot { + function: Function, + insertion_block: Option, + data: DataPoolLengths, + local_slots: HashMap, + local_kinds: HashMap, + local_types: TypeEnv, + initialized_slots: HashSet, + constants: HashMap, + loop_stack: Vec, + finally_stack: Vec, + static_callable_locals: HashMap, + reflection_class_locals: HashMap, + reflection_function_locals: HashMap, + reflection_property_locals: HashMap, + reflection_method_locals: HashMap, + reflection_arg_array_locals: HashMap>, + fiber_start_sigs: HashMap, + ref_bound_locals: HashSet, + ref_cell_owner_locals: HashMap, + foreach_int_key_locals: HashSet, + array_conversions: HashMap, + speculating: bool, + closure_count: usize, + pending_static_callable_result: Option, + closure_counter: usize, + hidden_temp_counter: usize, + eval_barrier_active: bool, + eval_executed: bool, + eval_scope_read_param: Option, + eval_scope_read_names: HashSet, + eval_scope_write_names: HashSet, + eval_scope_flush_names: BTreeSet, +} + +/// Lengths of append-only intern pools captured for speculative rollback. +struct DataPoolLengths { + strings: usize, + float_literals: usize, + global_names: usize, + function_names: usize, + class_names: usize, + method_names: usize, + property_names: usize, +} + +impl DataPoolLengths { + /// Captures the current length of every intern pool. + fn capture(data: &DataPool) -> Self { + Self { + strings: data.strings.len(), + float_literals: data.float_literals.len(), + global_names: data.global_names.len(), + function_names: data.function_names.len(), + class_names: data.class_names.len(), + method_names: data.method_names.len(), + property_names: data.property_names.len(), + } + } + + /// Removes entries interned after the snapshot. + fn truncate(&self, data: &mut DataPool) { + data.strings.truncate(self.strings); + data.float_literals.truncate(self.float_literals); + data.global_names.truncate(self.global_names); + data.function_names.truncate(self.function_names); + data.class_names.truncate(self.class_names); + data.method_names.truncate(self.method_names); + data.property_names.truncate(self.property_names); + } +} + const EVAL_CONTEXT_LOCAL_NAME: &str = "__eir_eval_context"; const EVAL_SCOPE_LOCAL_NAME: &str = "__eir_eval_scope"; const EVAL_GLOBAL_SCOPE_LOCAL_NAME: &str = "__eir_eval_global_scope"; @@ -148,6 +222,10 @@ pub(crate) struct LoweringContext<'m, 'f> { /// still promoting for keys that may be strings (generic `Array(Mixed)`, /// `AssocArray`, `Mixed`, `Union` sources). foreach_int_key_locals: HashSet, + /// Joined storage representation conversions observed while lowering this function. + array_conversions: HashMap, + /// Whether the current statement lowering is a disposable discovery pass. + speculating: bool, pub return_type: IrType, pub return_php_type: PhpType, /// `true` when the function/closure being lowered returns by reference (`function &f()`), @@ -243,6 +321,8 @@ impl<'m, 'f> LoweringContext<'m, 'f> { ref_bound_locals: HashSet::new(), ref_cell_owner_locals: HashMap::new(), foreach_int_key_locals: HashSet::new(), + array_conversions: HashMap::new(), + speculating: false, return_type, return_php_type, by_ref_return: false, @@ -264,6 +344,80 @@ impl<'m, 'f> LoweringContext<'m, 'f> { } } + /// Captures every mutable field touched by speculative statement lowering. + pub(crate) fn snapshot(&self) -> LoweringSnapshot { + LoweringSnapshot { + function: self.builder.snapshot_function(), + insertion_block: self.builder.insertion_block(), + data: DataPoolLengths::capture(self.data), + local_slots: self.local_slots.clone(), + local_kinds: self.local_kinds.clone(), + local_types: self.local_types.clone(), + initialized_slots: self.initialized_slots.clone(), + constants: self.constants.clone(), + loop_stack: self.loop_stack.clone(), + finally_stack: self.finally_stack.clone(), + static_callable_locals: self.static_callable_locals.clone(), + reflection_class_locals: self.reflection_class_locals.clone(), + reflection_function_locals: self.reflection_function_locals.clone(), + reflection_property_locals: self.reflection_property_locals.clone(), + reflection_method_locals: self.reflection_method_locals.clone(), + reflection_arg_array_locals: self.reflection_arg_array_locals.clone(), + fiber_start_sigs: self.fiber_start_sigs.clone(), + ref_bound_locals: self.ref_bound_locals.clone(), + ref_cell_owner_locals: self.ref_cell_owner_locals.clone(), + foreach_int_key_locals: self.foreach_int_key_locals.clone(), + array_conversions: self.array_conversions.clone(), + speculating: self.speculating, + closure_count: self.closures.len(), + pending_static_callable_result: self.pending_static_callable_result.clone(), + closure_counter: self.closure_counter, + hidden_temp_counter: self.hidden_temp_counter, + eval_barrier_active: self.eval_barrier_active, + eval_executed: self.eval_executed, + eval_scope_read_param: self.eval_scope_read_param.clone(), + eval_scope_read_names: self.eval_scope_read_names.clone(), + eval_scope_write_names: self.eval_scope_write_names.clone(), + eval_scope_flush_names: self.eval_scope_flush_names.clone(), + } + } + + /// Restores state captured before a discarded speculative lowering. + pub(crate) fn restore(&mut self, snapshot: LoweringSnapshot) { + self.builder.restore_function(snapshot.function); + self.builder.restore_insertion_cursor(snapshot.insertion_block); + snapshot.data.truncate(self.data); + self.local_slots = snapshot.local_slots; + self.local_kinds = snapshot.local_kinds; + self.local_types = snapshot.local_types; + self.initialized_slots = snapshot.initialized_slots; + self.constants = snapshot.constants; + self.loop_stack = snapshot.loop_stack; + self.finally_stack = snapshot.finally_stack; + self.static_callable_locals = snapshot.static_callable_locals; + self.reflection_class_locals = snapshot.reflection_class_locals; + self.reflection_function_locals = snapshot.reflection_function_locals; + self.reflection_property_locals = snapshot.reflection_property_locals; + self.reflection_method_locals = snapshot.reflection_method_locals; + self.reflection_arg_array_locals = snapshot.reflection_arg_array_locals; + self.fiber_start_sigs = snapshot.fiber_start_sigs; + self.ref_bound_locals = snapshot.ref_bound_locals; + self.ref_cell_owner_locals = snapshot.ref_cell_owner_locals; + self.foreach_int_key_locals = snapshot.foreach_int_key_locals; + self.array_conversions = snapshot.array_conversions; + self.speculating = snapshot.speculating; + self.closures.truncate(snapshot.closure_count); + self.pending_static_callable_result = snapshot.pending_static_callable_result; + self.closure_counter = snapshot.closure_counter; + self.hidden_temp_counter = snapshot.hidden_temp_counter; + self.eval_barrier_active = snapshot.eval_barrier_active; + self.eval_executed = snapshot.eval_executed; + self.eval_scope_read_param = snapshot.eval_scope_read_param; + self.eval_scope_read_names = snapshot.eval_scope_read_names; + self.eval_scope_write_names = snapshot.eval_scope_write_names; + self.eval_scope_flush_names = snapshot.eval_scope_flush_names; + } + /// Returns the canonical PHP source path associated with this lowered body, if known. pub(crate) fn source_path(&self) -> Option<&str> { self.source_path.as_deref() @@ -426,12 +580,50 @@ impl<'m, 'f> LoweringContext<'m, 'f> { /// Updates the current known PHP type for a local. pub(crate) fn set_local_type(&mut self, name: &str, ty: PhpType) { + self.set_local_type_impl(name, ty, true); + } + + /// Updates a local type and optionally records a runtime array-layout conversion. + fn set_local_type_impl(&mut self, name: &str, ty: PhpType, track_array_conversion: bool) { if let Some(slot) = self.local_slots.get(name).copied() { self.builder.widen_local_storage_type(slot, ty.clone()); } + if track_array_conversion { + if let Some(target) = array_storage_conversion(self.local_types.get(name), &ty) { + let joined = self + .array_conversions + .get(name) + .map_or(target.clone(), |previous| { + join_array_storage_conversion(previous, &target) + }); + self.array_conversions.insert(name.to_string(), joined); + } + } self.local_types.insert(name.to_string(), ty); } + /// Returns the joined array representation conversion observed for a local. + pub(crate) fn array_conversion(&self, name: &str) -> Option<&PhpType> { + self.array_conversions.get(name) + } + + /// Clears conversion facts for the candidate locals before a discovery pass. + pub(crate) fn forget_array_conversions(&mut self, names: &[String]) { + for name in names { + self.array_conversions.remove(name); + } + } + + /// Returns whether lowering is currently running only to discover conversions. + pub(crate) fn is_speculating(&self) -> bool { + self.speculating + } + + /// Sets speculative-lowering mode and returns its previous value. + pub(crate) fn set_speculating(&mut self, speculating: bool) -> bool { + std::mem::replace(&mut self.speculating, speculating) + } + /// Updates only the flow-sensitive PHP type fact for a local. pub(crate) fn set_local_logical_type(&mut self, name: &str, ty: PhpType) { self.local_types.insert(name.to_string(), ty); @@ -467,6 +659,29 @@ impl<'m, 'f> LoweringContext<'m, 'f> { slot } + /// Rebinds a by-value array/hash parameter to an owning copy-on-write shadow slot. + /// + /// Call sites pass container pointers as borrows. Acquiring the value into a fresh local makes + /// the first callee mutation observe refcount two and split instead of modifying caller storage. + pub(crate) fn privatize_container_param( + &mut self, + name: &str, + php_type: &PhpType, + span: Option, + ) { + let borrowed = self.load_local(name, span); + let shadow = self.builder.add_local( + Some(format!("{}#cow", name)), + value_ir_type(php_type), + php_type.clone(), + LocalKind::PhpLocal, + ); + self.local_slots.insert(name.to_string(), shadow); + self.local_kinds + .insert(name.to_string(), LocalKind::PhpLocal); + self.store_local(name, borrowed, php_type.clone(), span); + } + /// Marks a local slot as initialized by caller or synthetic setup. pub(crate) fn mark_local_initialized(&mut self, name: &str) { if let Some(slot) = self.local_slots.get(name) { @@ -479,11 +694,39 @@ impl<'m, 'f> LoweringContext<'m, 'f> { self.initialized_slots.clone() } + /// Returns whether a local slot is definitely initialized at this point. + pub(crate) fn slot_is_initialized(&self, slot: LocalSlotId) -> bool { + self.initialized_slots.contains(&slot) + } + /// Replaces the definitely-initialized local set after branch lowering or merge analysis. pub(crate) fn restore_initialized_slots(&mut self, initialized_slots: HashSet) { self.initialized_slots = initialized_slots; } + /// Captures the flow-sensitive local-type facts at a control-flow split. + pub(crate) fn local_types_snapshot(&self) -> TypeEnv { + self.local_types.clone() + } + + /// Restores only the flow-sensitive type facts at a branch split. + /// + /// This deliberately avoids `set_local_type`, which would also widen the shared frame slot; + /// storage widening must survive while each branch gets its own logical type environment. + pub(crate) fn restore_local_types(&mut self, types: TypeEnv) { + self.local_types = types; + } + + /// Returns whether a local is backed by program-global storage. + pub(crate) fn local_uses_global_storage(&self, name: &str) -> bool { + let kind = self + .local_kinds + .get(name) + .copied() + .unwrap_or(LocalKind::PhpLocal); + self.uses_global_storage(name, kind) + } + /// Records that a local currently aliases by-reference storage. pub(crate) fn mark_ref_bound_local(&mut self, name: &str) { self.ref_bound_locals.insert(name.to_string()); @@ -1369,7 +1612,23 @@ impl<'m, 'f> LoweringContext<'m, 'f> { php_type: PhpType, span: Option, ) -> LoweredValue { - self.store_mutated_local_impl(name, value, php_type, span, true) + self.store_mutated_local_impl(name, value, php_type, span, true, true) + } + + /// Stores a by-reference call's internal array normalization without hoisting it. + /// + /// Call lowering deliberately captures the operand's concrete representation before adapting + /// an `array` by-reference argument. Treating that adaptation as a statement-level + /// conversion makes the fixed-point pass pre-widen the operand and breaks concrete builtin + /// dispatch such as `sort()` and `array_pop()`. + pub(crate) fn store_call_normalized_local( + &mut self, + name: &str, + value: LoweredValue, + php_type: PhpType, + span: Option, + ) -> LoweredValue { + self.store_mutated_local_impl(name, value, php_type, span, true, false) } /// Stores a mutation result whose previous boxed local owner was released beforehand. @@ -1380,7 +1639,7 @@ impl<'m, 'f> LoweringContext<'m, 'f> { php_type: PhpType, span: Option, ) -> LoweredValue { - self.store_mutated_local_impl(name, value, php_type, span, false) + self.store_mutated_local_impl(name, value, php_type, span, false, true) } /// Implements consuming local storeback with caller-selected cleanup timing. @@ -1391,6 +1650,7 @@ impl<'m, 'f> LoweringContext<'m, 'f> { php_type: PhpType, span: Option, release_previous: bool, + track_array_conversion: bool, ) -> LoweredValue { self.clear_static_callable_local(name); self.clear_reflection_class_local(name); @@ -1408,12 +1668,12 @@ impl<'m, 'f> LoweringContext<'m, 'f> { let slot = self.declare_local(name, php_type.clone()); if uses_global { self.store_global_name(name, slot, value, span); - self.set_local_type(name, php_type); + self.set_local_type_impl(name, php_type, track_array_conversion); return value; } let is_ref_bound = self.is_ref_bound_local(name) && previous_kind == LocalKind::PhpLocal; let value_type = self.builder.value_php_type(value.value).codegen_repr(); - self.set_local_type(name, php_type.clone()); + self.set_local_type_impl(name, php_type.clone(), track_array_conversion); let storage_type = self.builder.local_php_type(slot).codegen_repr(); if release_previous && !is_ref_bound @@ -1506,6 +1766,29 @@ impl<'m, 'f> LoweringContext<'m, 'f> { self.initialized_slots.insert(owner_slot); } + /// Widens a concrete local to boxed `Mixed` storage, then promotes that storage to a + /// durable heap reference cell suitable for an escaping by-reference parameter. + pub(crate) fn promote_local_mixed_ref_cell(&mut self, name: &str, span: Option) { + if self.is_ref_bound_local(name) && self.local_type(name).codegen_repr() == PhpType::Mixed { + return; + } + if self.local_type(name).codegen_repr() != PhpType::Mixed { + let source = self.load_local(name, span); + let boxed = self.emit_value( + Op::MixedBox, + vec![source.value], + None, + PhpType::Mixed, + Op::MixedBox.default_effects(), + span, + ); + self.store_local(name, boxed, PhpType::Mixed, span); + } + if !self.is_ref_bound_local(name) { + self.promote_local_ref_cell(name, span); + } + } + /// Binds one local name to the same ref-cell pointer as another local. pub(crate) fn alias_local_ref_cell(&mut self, target: &str, source: &str, span: Option) { if target == source { @@ -1949,7 +2232,16 @@ impl<'m, 'f> LoweringContext<'m, 'f> { let op = self.builder.value_defining_op(value); (matches!(php_type, PhpType::Mixed | PhpType::Union(_)) || (php_type.is_refcounted() && php_type != PhpType::Str)) - && matches!(op, Some(Op::ArrayGet | Op::HashGet | Op::HashGetSilent)) + && matches!( + op, + Some( + Op::ArrayGet + | Op::ArrayGetForWrite + | Op::HashGet + | Op::HashGetSilent + | Op::HashGetForWrite + ) + ) } /// Returns whether an index-read receiver is itself an owned intermediate @@ -1975,8 +2267,10 @@ impl<'m, 'f> LoweringContext<'m, 'f> { Some( Op::ArrayGet | Op::ArrayGetSilent + | Op::ArrayGetForWrite | Op::HashGet | Op::HashGetSilent + | Op::HashGetForWrite | Op::ArrayGetMixedKey | Op::ArrayGetMixedKeySilent ) @@ -1991,7 +2285,7 @@ impl<'m, 'f> LoweringContext<'m, 'f> { match inst.immediate { Some(Immediate::RuntimeCall( crate::ir::RuntimeCallTarget::ArrayFetchForWrite, - )) => false, + )) => matches!(inst.result_php_type.codegen_repr(), PhpType::Mixed | PhpType::Union(_)), Some(Immediate::RuntimeCall(crate::ir::RuntimeCallTarget::Function(target))) => { matches!( target.result_ownership(), diff --git a/src/ir_lower/expr/array_access.rs b/src/ir_lower/expr/array_access.rs index f9fa95d385..3922105316 100644 --- a/src/ir_lower/expr/array_access.rs +++ b/src/ir_lower/expr/array_access.rs @@ -212,8 +212,18 @@ pub(super) fn lower_array_access_from_value( let mut index_value = lower_expr(ctx, index); let op = match array_value.ir_type { IrType::Heap(IrHeapKind::Array) => { - let index_ty = index_expr_key_type(ctx, index); - if index_ty == PhpType::Int { + let index_ty = lowered_index_expr_key_type(ctx, index, index_value.value); + // A genuinely boxed Mixed key is materialized by array codegen. Do not coerce it + // here: string keys would become integer zero, while checked integer loop counters + // use I64 and therefore still take the ordinary coercion path below. + let index_is_mixed = matches!(index_value.ir_type, IrType::Heap(IrHeapKind::Mixed)); + if index_is_mixed { + if warn_on_missing { + Op::ArrayGet + } else { + Op::ArrayGetSilent + } + } else if index_ty == PhpType::Int { index_value = coerce_to_int_at_span(ctx, index_value, Some(index.span)); if warn_on_missing { Op::ArrayGet @@ -347,6 +357,41 @@ pub(crate) fn index_expr_key_type(_ctx: &LoweringContext<'_, '_>, index: &Expr) normalized_array_key_type(index, ty) } +/// Refines a read key's syntactic type from its lowered SSA value when it is definitely a string. +pub(super) fn lowered_index_expr_key_type( + ctx: &LoweringContext<'_, '_>, + index: &Expr, + index_value: ValueId, +) -> PhpType { + let syntactic = index_expr_key_type(ctx, index); + if syntactic == PhpType::Int && ctx.builder.value_php_type(index_value) == PhpType::Str { + return normalized_array_key_type(index, PhpType::Str); + } + syntactic +} + +/// Refines an `isset` key from its lowered value, including boxed Mixed keys. +pub(super) fn isset_index_expr_key_type( + ctx: &LoweringContext<'_, '_>, + index: &Expr, + index_value: ValueId, +) -> PhpType { + let syntactic = index_expr_key_type(ctx, index); + if syntactic != PhpType::Int { + return syntactic; + } + let lowered = ctx.builder.value_php_type(index_value); + if matches!(lowered.codegen_repr(), PhpType::TaggedScalar) { + return syntactic; + } + match lowered { + ty @ (PhpType::Str | PhpType::Mixed | PhpType::Union(_)) => { + normalized_array_key_type(index, ty) + } + _ => syntactic, + } +} + /// Returns the best PHP result type for a lowered array/string/hash access. pub(super) fn array_access_result_type( ctx: &LoweringContext<'_, '_>, diff --git a/src/ir_lower/expr/call_arg_coercion.rs b/src/ir_lower/expr/call_arg_coercion.rs index 9b3e50b6c8..345ff9fb65 100644 --- a/src/ir_lower/expr/call_arg_coercion.rs +++ b/src/ir_lower/expr/call_arg_coercion.rs @@ -127,7 +127,7 @@ pub(super) fn lower_by_ref_array_arg_with_signature( Op::ArrayToMixed.default_effects(), Some(arg.span), ); - ctx.store_mutated_local(name, converted, array_ty, Some(arg.span)); + ctx.store_call_normalized_local(name, converted, array_ty, Some(arg.span)); Some(ctx.load_local(name, Some(arg.span)).value) } diff --git a/src/ir_lower/expr/function_calls.rs b/src/ir_lower/expr/function_calls.rs index 16c25732f6..9d4ce2b6b0 100644 --- a/src/ir_lower/expr/function_calls.rs +++ b/src/ir_lower/expr/function_calls.rs @@ -121,11 +121,12 @@ pub(super) fn lower_function_call(ctx: &mut LoweringContext<'_, '_>, name: &Name .function(canonical) .cloned() .unwrap_or(ReturnArgAlias::Unknown); - release_owned_call_arg_temporaries( + release_owned_call_arg_temporaries_with_signature( ctx, &operands, Some(call.value), &return_alias, + sig.as_ref(), expr.span, ); return call; @@ -304,4 +305,3 @@ pub(super) fn registry_builtin_result_type( }; Some(normalize_value_php_type(resolved)) } - diff --git a/src/ir_lower/expr/method_calls.rs b/src/ir_lower/expr/method_calls.rs index 81534cab59..8967849431 100644 --- a/src/ir_lower/expr/method_calls.rs +++ b/src/ir_lower/expr/method_calls.rs @@ -132,6 +132,7 @@ pub(super) fn lower_method_call( let result_type = method_call_result_type(ctx, object.value, dispatch_method, op, expr); let mut operands = vec![object.value]; let sig = method_call_argument_signature(ctx, object_expr, object.value, dispatch_method); + promote_pdo_binding_ref_argument(ctx, object.value, dispatch_method, args); let arg_values = lower_args_with_signature(ctx, sig.as_ref(), args); operands.extend(arg_values.iter().copied()); let data = ctx.intern_string(dispatch_method); @@ -144,11 +145,12 @@ pub(super) fn lower_method_call( Some(expr.span), ); let return_alias = method_return_arg_alias(ctx, object.value, dispatch_method); - release_owned_call_arg_temporaries( + release_owned_call_arg_temporaries_with_signature( ctx, &arg_values, Some(call.value), &return_alias, + sig.as_ref(), expr.span, ); release_owning_receiver_temporary(ctx, object, expr.span); @@ -315,4 +317,3 @@ pub(super) fn lower_nullable_regular_method_call( ctx.builder.position_at_end(merge); take_owned_temp(ctx, &temp_name, expr.span) } - diff --git a/src/ir_lower/expr/method_metadata.rs b/src/ir_lower/expr/method_metadata.rs index e4b6b26569..f9f8eee4e8 100644 --- a/src/ir_lower/expr/method_metadata.rs +++ b/src/ir_lower/expr/method_metadata.rs @@ -30,6 +30,52 @@ pub(super) fn method_signature( None } +/// Promotes the writable destination used by PDOStatement binding methods to a durable Mixed cell. +pub(super) fn promote_pdo_binding_ref_argument( + ctx: &mut LoweringContext<'_, '_>, + object: crate::ir::ValueId, + method: &str, + args: &[Expr], +) { + if !type_may_be_pdo_statement(ctx, &ctx.builder.value_php_type(object)) { + return; + } + let parameter_name = match php_symbol_key(method).as_str() { + "bindparam" => "variable", + "bindcolumn" => "var", + _ => return, + }; + let expanded_args = crate::types::call_args::expand_static_assoc_spread_args(args); + let argument = expanded_args + .iter() + .enumerate() + .find_map(|(index, arg)| match &arg.kind { + ExprKind::NamedArg { name, value } if name == parameter_name => Some(value.as_ref()), + ExprKind::NamedArg { .. } => None, + _ if index == 1 => Some(arg), + _ => None, + }); + let Some(Expr { + kind: ExprKind::Variable(name), + span, + }) = argument + else { + return; + }; + ctx.promote_local_mixed_ref_cell(name, Some(*span)); +} + +/// Returns whether a receiver type can dispatch to PDOStatement binding methods. +fn type_may_be_pdo_statement(ctx: &LoweringContext<'_, '_>, ty: &PhpType) -> bool { + match ty { + PhpType::Object(class) => class_extends_class(ctx, class, "PDOStatement"), + PhpType::Union(members) => members + .iter() + .any(|member| type_may_be_pdo_statement(ctx, member)), + _ => false, + } +} + /// Returns the conservative return-to-argument alias summary for a method dispatch. /// /// A non-final receiver type includes every closed-world descendant implementation, @@ -219,4 +265,3 @@ pub(super) fn dynamic_method_receiver_needs_mixed_fallback(php_type: &PhpType) - _ => false, } } - diff --git a/src/ir_lower/expr/native_isset.rs b/src/ir_lower/expr/native_isset.rs index 815dbf3965..3339561942 100644 --- a/src/ir_lower/expr/native_isset.rs +++ b/src/ir_lower/expr/native_isset.rs @@ -80,7 +80,7 @@ pub(super) fn lower_native_isset_offset_probe_from_value( match array_value.ir_type { IrType::Heap(IrHeapKind::Array) => { let mut index_value = lower_expr(ctx, index); - let index_ty = index_expr_key_type(ctx, index); + let index_ty = isset_index_expr_key_type(ctx, index, index_value.value); if index_ty == PhpType::Int { index_value = coerce_to_int_at_span(ctx, index_value, Some(index.span)); ctx.emit_value( @@ -92,14 +92,13 @@ pub(super) fn lower_native_isset_offset_probe_from_value( Some(expr.span), ) } else { - // String or mixed key on indexed storage: read through the - // mixed-key runtime path and check if the result is null. + // `isset()` is a silent probe even when the key is absent. let read_value = ctx.emit_value( - Op::ArrayGetMixedKey, + Op::ArrayGetMixedKeySilent, vec![array_value.value, index_value.value], None, PhpType::Mixed, - Op::ArrayGetMixedKey.default_effects(), + Op::ArrayGetMixedKeySilent.default_effects(), Some(expr.span), ); let is_null = ctx.emit_value( @@ -110,6 +109,11 @@ pub(super) fn lower_native_isset_offset_probe_from_value( Op::IsNull.default_effects(), Some(expr.span), ); + crate::ir_lower::ownership::release_if_owned( + ctx, + read_value, + Some(expr.span), + ); let zero = ctx.emit_value( Op::ConstI64, Vec::new(), @@ -365,4 +369,3 @@ pub(super) fn lower_nullable_magic_property_isset( ctx.builder.position_at_end(merge); ctx.load_local(&temp_name, Some(arg.span)) } - diff --git a/src/ir_lower/expr/nullable_method_calls.rs b/src/ir_lower/expr/nullable_method_calls.rs index 7cbeadd431..06825c2281 100644 --- a/src/ir_lower/expr/nullable_method_calls.rs +++ b/src/ir_lower/expr/nullable_method_calls.rs @@ -131,6 +131,7 @@ pub(super) fn lower_method_call_with_receiver( let result_type = method_call_result_type(ctx, object.value, dispatch_method, op, expr); let mut operands = vec![object.value]; let sig = method_signature(ctx, object.value, dispatch_method); + promote_pdo_binding_ref_argument(ctx, object.value, dispatch_method, args); let arg_values = lower_args_with_signature(ctx, sig.as_ref(), args); operands.extend(arg_values.iter().copied()); let data = ctx.intern_string(dispatch_method); @@ -143,11 +144,12 @@ pub(super) fn lower_method_call_with_receiver( Some(expr.span), ); let return_alias = method_return_arg_alias(ctx, object.value, dispatch_method); - release_owned_call_arg_temporaries( + release_owned_call_arg_temporaries_with_signature( ctx, &arg_values, Some(call.value), &return_alias, + sig.as_ref(), expr.span, ); release_owning_receiver_temporary(ctx, object, expr.span); @@ -221,6 +223,10 @@ pub(super) fn release_owned_call_arg_temporaries_with_signature( ir_type: value_ir_type(&php_type), }; if ctx.value_is_owning_temporary(lowered) { + // PHP callees acquire by-value array/hash parameters into owning COW shadow slots. + // Their result therefore cannot be an unretained alias of the caller's argument. + let callee_owns = signature + .is_some_and(|signature| signature.param_is_callee_owned(parameter_index)); let independently_boxed = signature.is_some_and(|signature| { call_arg_gets_independent_mixed_box(signature, parameter_index, &php_type) }); @@ -246,7 +252,7 @@ pub(super) fn release_owned_call_arg_temporaries_with_signature( || (return_alias.proven_aliases_parameter(parameter_index) && ctx.arg_and_result_types_can_alias(*value, result)) }); - if !independently_boxed && result_reuses_arg { + if !callee_owns && !independently_boxed && result_reuses_arg { // Both suppression reasons above are MAY facts, so an unconditional skip is // right only on the calls that actually hand the payload back. Emitting a // conditional release instead lets each call decide at runtime: the codegen @@ -350,4 +356,3 @@ pub(super) fn release_owning_receiver_temporary( crate::ir_lower::ownership::release_if_owned(ctx, receiver, Some(span)); } } - diff --git a/src/ir_lower/expr/object_construction.rs b/src/ir_lower/expr/object_construction.rs index 72ffaeef88..d30b991dae 100644 --- a/src/ir_lower/expr/object_construction.rs +++ b/src/ir_lower/expr/object_construction.rs @@ -338,11 +338,19 @@ pub(super) fn lower_new_dynamic( expr: &Expr, ) -> LoweredValue { let mut operands = vec![lower_expr(ctx, name_expr).value]; - operands.extend(lower_args(ctx, args)); + let uses_runtime_arg_container = args.iter().any(is_spread_arg) + || crate::types::call_args::has_named_args(args); + if uses_runtime_arg_container { + let arg_container = lower_untyped_descriptor_invoker_arg_container(ctx, args, expr.span) + .expect("dynamic constructor arguments always have a runtime container form"); + operands.push(arg_container.value); + } else { + operands.extend(lower_args(ctx, args)); + } ctx.emit_value( Op::DynamicObjectNewMixed, operands, - None, + uses_runtime_arg_container.then_some(Immediate::Bool(true)), PhpType::Mixed, Op::DynamicObjectNewMixed.default_effects(), Some(expr.span), @@ -382,4 +390,3 @@ pub(super) fn constructor_signature<'a>( .get(class_name.as_str().trim_start_matches('\\')) .and_then(|class_info| class_info.methods.get(&key)) } - diff --git a/src/ir_lower/function.rs b/src/ir_lower/function.rs index 143518f329..2b9d61ec9d 100644 --- a/src/ir_lower/function.rs +++ b/src/ir_lower/function.rs @@ -941,6 +941,31 @@ fn lower_body_into_function( ctx.mark_ref_bound_local(name); } } + // PHP passes arrays BY VALUE. The call site hands the callee a `+0` borrow of the caller's + // array, so `__rt_array_ensure_unique` (which only splits at refcount >= 2) stayed inert and + // every write in the callee landed in the CALLER's storage. Re-bind each by-value container + // parameter to an owning shadow slot, which restores the refcount the copy-on-write split + // depends on. This one site is the single funnel for free functions, methods, static methods + // and closures, so every call flavour — including `call_user_func`, dynamic `$f(...)` and + // recursion — is covered without any per-flavour code. + // + // By-reference parameters are excluded by definition: `array &$a` must alias, not copy. + // `$this` is excluded because it is an object, never a container. + for (index, (name, php_type)) in params.iter().enumerate() { + if by_ref_params.get(index).copied().unwrap_or(false) { + continue; + } + if name == "this" { + continue; + } + if !matches!( + php_type.codegen_repr(), + PhpType::Array(_) | PhpType::AssocArray { .. } + ) { + continue; + } + ctx.privatize_container_param(name, php_type, None); + } seed_recursive_closure_binding(&mut ctx, recursive_closure_binding); for stmt in body { crate::ir_lower::stmt::lower_stmt(&mut ctx, stmt); @@ -1422,6 +1447,19 @@ fn direct_closure_return_expr_type( params: &[(String, PhpType)], classes: &std::collections::HashMap, ) -> PhpType { + if let ExprKind::ScopedConstantAccess { + receiver: crate::parser::ast::StaticReceiver::Named(class_name), + name, + } = &expr.kind + { + let normalized = class_name.as_str().trim_start_matches('\\'); + if let Some(value) = classes + .get(normalized) + .and_then(|class_info| class_info.constants.get(name)) + { + return crate::types::checker::infer_expr_type_syntactic(value); + } + } if let ExprKind::Variable(name) = &expr.kind { if let Some((_, php_type, _)) = captures .iter() diff --git a/src/ir_lower/program/runtime_features.rs b/src/ir_lower/program/runtime_features.rs index 2c8e464cd3..4bb742de65 100644 --- a/src/ir_lower/program/runtime_features.rs +++ b/src/ir_lower/program/runtime_features.rs @@ -16,6 +16,7 @@ pub(in crate::ir_lower) fn include_lowered_runtime_features(module: &mut Module) module.required_runtime_features.mb_strlen |= features.mb_strlen; module.required_runtime_features.phar_archive |= features.phar_archive; module.required_runtime_features.descriptor_invoker |= features.descriptor_invoker; + module.required_runtime_features.pdo_udf |= features.pdo_udf; module.required_runtime_features.eval_bridge |= features.eval_bridge; module.required_runtime_features.eval_scope |= features.eval_scope; } @@ -67,6 +68,9 @@ pub(super) fn lowered_runtime_features(module: &Module) -> RuntimeFeatures { Op::ExprCall | Op::CallableDescriptorInvoke => { features.descriptor_invoker = true; } + Op::PdoAdapterAddr => { + features.pdo_udf = true; + } _ => {} } } @@ -365,4 +369,3 @@ pub(super) fn eval_literal_static_method_supported_by_module( }; crate::eval_aot::static_function_signature_supported(signature, args) } - diff --git a/src/ir_lower/stmt/conditionals.rs b/src/ir_lower/stmt/conditionals.rs index a36bbb9a41..5d239a0ea6 100644 --- a/src/ir_lower/stmt/conditionals.rs +++ b/src/ir_lower/stmt/conditionals.rs @@ -8,8 +8,19 @@ //! - Preserves statement ordering, CFG shape, EIR effects, and ownership contracts. use super::*; +use crate::types::TypeEnv; -/// Lowers an `if` / `elseif` / `else` chain and terminates unreachable merge blocks explicitly. +/// One reachable arm of an `if` chain together with its deferred merge edge. +struct IfArmExit { + /// Empty block filled after every sibling arm has been lowered. + tail: BlockId, + /// Flow-sensitive local types at the end of this arm. + types: TypeEnv, + /// Definitely-initialized slots at the end of this arm. + initialized: HashSet, +} + +/// Lowers an `if` / `elseif` / `else` chain and joins all reachable arm types once. pub(super) fn lower_if( ctx: &mut LoweringContext<'_, '_>, condition: &Expr, @@ -19,6 +30,7 @@ pub(super) fn lower_if( span: Span, ) { let merge = ctx.builder.create_named_block("if.merge", Vec::new()); + let mut arms = Vec::new(); let merge_reachable = lower_if_chain( ctx, condition, @@ -26,8 +38,10 @@ pub(super) fn lower_if( elseif_clauses, else_body, merge, + &mut arms, span, ); + finish_if_type_join(ctx, arms, merge, span); ctx.builder.position_at_end(merge); if !merge_reachable { ctx.builder.terminate(Terminator::Unreachable); @@ -35,19 +49,22 @@ pub(super) fn lower_if( ctx.clear_static_callable_locals(); } -/// Recursively emits one condition node in an `if` chain and reports whether the merge is reachable. -pub(super) fn lower_if_chain( +/// Recursively emits one condition node and records every reachable arm against one shared merge. +#[allow(clippy::too_many_arguments)] +fn lower_if_chain( ctx: &mut LoweringContext<'_, '_>, condition: &Expr, then_body: &[Stmt], elseif_clauses: &[(Expr, Vec)], else_body: Option<&[Stmt]>, merge: BlockId, + arms: &mut Vec, span: Span, ) -> bool { let cond_value = lower_expr(ctx, condition); let cond_value = ctx.truthy_consuming(cond_value, Some(condition.span)); let split_initialized = ctx.initialized_slots_snapshot(); + let split_types = ctx.local_types_snapshot(); let then_block = ctx.builder.create_named_block("if.then", Vec::new()); let else_block = ctx.builder.create_named_block("if.else", Vec::new()); ctx.builder.terminate(Terminator::CondBr { @@ -60,25 +77,36 @@ pub(super) fn lower_if_chain( ctx.builder.position_at_end(then_block); ctx.restore_initialized_slots(split_initialized.clone()); + ctx.restore_local_types(split_types.clone()); lower_block(ctx, then_body); let then_initialized = ctx.initialized_slots_snapshot(); let mut merge_reachable = false; let then_reachable = !ctx.builder.insertion_block_is_terminated(); if then_reachable { merge_reachable = true; - branch_to(ctx, merge); + record_if_arm_exit(ctx, arms); } ctx.clear_static_callable_locals(); ctx.builder.position_at_end(else_block); ctx.restore_initialized_slots(split_initialized.clone()); + ctx.restore_local_types(split_types); let else_reachable = if let Some(((next_condition, next_body), rest)) = elseif_clauses.split_first() { - lower_if_chain(ctx, next_condition, next_body, rest, else_body, merge, span) + lower_if_chain( + ctx, + next_condition, + next_body, + rest, + else_body, + merge, + arms, + span, + ) } else if let Some(else_body) = else_body { lower_block(ctx, else_body); if !ctx.builder.insertion_block_is_terminated() { - branch_to(ctx, merge); + record_if_arm_exit(ctx, arms); true } else { false @@ -86,7 +114,7 @@ pub(super) fn lower_if_chain( } else { lower_noop(ctx, span); if !ctx.builder.insertion_block_is_terminated() { - branch_to(ctx, merge); + record_if_arm_exit(ctx, arms); true } else { false @@ -104,6 +132,146 @@ pub(super) fn lower_if_chain( merge_reachable } +/// Defers one reachable arm's merge edge so representation conversions can be inserted later. +fn record_if_arm_exit(ctx: &mut LoweringContext<'_, '_>, arms: &mut Vec) { + let tail = ctx.builder.create_named_block("if.arm", Vec::new()); + ctx.builder.terminate(Terminator::Br { + target: tail, + args: Vec::new(), + }); + arms.push(IfArmExit { + tail, + types: ctx.local_types_snapshot(), + initialized: ctx.initialized_slots_snapshot(), + }); +} + +/// Reconciles flow-sensitive types and indexed-array layouts on all incoming merge edges. +fn finish_if_type_join( + ctx: &mut LoweringContext<'_, '_>, + arms: Vec, + merge: BlockId, + span: Span, +) { + if arms.len() < 2 { + if let Some(arm) = arms.first() { + ctx.restore_local_types(arm.types.clone()); + } + for arm in &arms { + ctx.builder.position_at_end(arm.tail); + ctx.builder.terminate(Terminator::Br { + target: merge, + args: Vec::new(), + }); + } + return; + } + + let joined = join_arm_types(ctx, &arms); + let saved_types = ctx.local_types_snapshot(); + for arm in &arms { + ctx.restore_local_types(arm.types.clone()); + let conversions = arm_conversions(arm, &joined); + ctx.builder.position_at_end(arm.tail); + widen_indexed_arrays_to_mixed(ctx, &conversions, span); + ctx.builder.terminate(Terminator::Br { + target: merge, + args: Vec::new(), + }); + } + ctx.restore_local_types(saved_types); + for (name, ty) in joined { + ctx.set_local_type(&name, ty); + } +} + +/// Computes the common post-merge type facts that every reachable arm can represent safely. +fn join_arm_types(ctx: &LoweringContext<'_, '_>, arms: &[IfArmExit]) -> TypeEnv { + let Some(first) = arms.first() else { + return TypeEnv::new(); + }; + let mut names = first.types.keys().cloned().collect::>(); + names.sort(); + + let mut joined = TypeEnv::new(); + 'names: for name in names { + let mut arm_types = Vec::with_capacity(arms.len()); + for arm in arms { + let Some(arm_type) = arm.types.get(&name) else { + continue 'names; + }; + arm_types.push(arm_type.codegen_repr()); + } + if arm_types.windows(2).all(|pair| pair[0] == pair[1]) { + continue; + } + if arm_types.iter().any(|ty| *ty == PhpType::Mixed) { + joined.insert(name, PhpType::Mixed); + continue; + } + + for arm_type in arm_types { + let PhpType::Array(_) = arm_type else { + continue 'names; + }; + } + if !arms + .iter() + .all(|arm| local_slot_is_convertible(ctx, &name, &arm.initialized)) + { + continue; + } + joined.insert(name, PhpType::Array(Box::new(PhpType::Mixed))); + } + joined +} + +/// Returns indexed-array locals whose current arm needs boxing before entering the merge. +fn arm_conversions(arm: &IfArmExit, joined: &TypeEnv) -> Vec { + let mut names = joined + .keys() + .filter(|name| { + matches!( + arm.types.get(name.as_str()).map(PhpType::codegen_repr), + Some(PhpType::Array(element)) if element.codegen_repr() != PhpType::Mixed + ) + }) + .cloned() + .collect::>(); + names.sort(); + names +} + +/// Returns whether one arm can safely convert the named local's array storage in place. +fn local_slot_is_convertible( + ctx: &LoweringContext<'_, '_>, + name: &str, + initialized: &HashSet, +) -> bool { + repr_fixpoint::local_slot_kind_is_convertible(ctx, name) + && ctx + .local_slots + .get(name) + .is_some_and(|slot| initialized.contains(slot)) +} + +/// Boxes indexed-array elements on an arm edge so all paths agree at the merge. +fn widen_indexed_arrays_to_mixed(ctx: &mut LoweringContext<'_, '_>, names: &[String], span: Span) { + let mixed_array_ty = PhpType::Array(Box::new(PhpType::Mixed)); + for name in names { + let array = ctx.load_local(name, Some(span)); + let converted = ctx.emit_value( + Op::ArrayToMixed, + vec![array.value], + None, + mixed_array_ty.clone(), + Op::ArrayToMixed.default_effects(), + Some(span), + ); + ctx.store_mutated_local(name, converted, mixed_array_ty.clone(), Some(span)); + } +} + /// Merges definitely-initialized locals from the reachable branches of an `if`. pub(super) fn merge_initialized_slots( split_initialized: &HashSet, @@ -222,4 +390,3 @@ pub(super) fn apply_loop_storage_contracts( } } } - diff --git a/src/ir_lower/stmt/instance_property_writes.rs b/src/ir_lower/stmt/instance_property_writes.rs index 5e34529ac0..06ebfa7e25 100644 --- a/src/ir_lower/stmt/instance_property_writes.rs +++ b/src/ir_lower/stmt/instance_property_writes.rs @@ -48,6 +48,13 @@ pub(super) fn lower_property_assign( value_expr, span, ); + // Property slots use their declared/inferred storage representation. In particular, an + // untyped property widened to Mixed needs a boxed cell even when this assignment is scalar. + let property_ty = object_property_type(ctx, object.value, property); + let value = match property_ty { + Some(ty) => coerce_typed_assign_value(ctx, value, &ty, span), + None => value, + }; if magic_set_receiver_has_method(ctx, object.value, property) { lower_magic_property_set(ctx, object.value, property, value, span); return; @@ -204,4 +211,3 @@ pub(super) fn contextualize_property_array_assignment( Some(span), ) } - diff --git a/src/ir_lower/stmt/loops.rs b/src/ir_lower/stmt/loops.rs index c78fb2fdfb..bf422323ce 100644 --- a/src/ir_lower/stmt/loops.rs +++ b/src/ir_lower/stmt/loops.rs @@ -87,7 +87,11 @@ pub(super) fn lower_do_while( ctx.clear_static_callable_locals(); } -/// Lowers a `for` loop. +/// Lowers a `for` loop after establishing its loop-carried storage representation. +/// +/// The fixed-point region starts below the initializer because an array created by the initializer +/// does not exist at the statement entry and therefore cannot be discovered by the outer +/// statement-level representation scan. pub(super) fn lower_for( ctx: &mut LoweringContext<'_, '_>, init: Option<&Stmt>, @@ -107,6 +111,23 @@ pub(super) fn lower_for( .or_else(|| body.first().map(|s| s.span)); apply_loop_storage_contracts(ctx, loop_span, contract_span); + repr_fixpoint::lower_for_body_at_type_fixpoint( + ctx, + loop_span, + condition, + update, + body, + |ctx| lower_for_once(ctx, condition, update, body), + ); +} + +/// Emits the control-flow graph, body, and update of a `for` loop exactly once. +fn lower_for_once( + ctx: &mut LoweringContext<'_, '_>, + condition: Option<&Expr>, + update: Option<&Stmt>, + body: &[Stmt], +) { let header = ctx.builder.create_named_block("for.cond", Vec::new()); let body_block = ctx.builder.create_named_block("for.body", Vec::new()); let update_block = ctx.builder.create_named_block("for.update", Vec::new()); diff --git a/src/ir_lower/stmt/mod.rs b/src/ir_lower/stmt/mod.rs index d617291184..28beca0e98 100644 --- a/src/ir_lower/stmt/mod.rs +++ b/src/ir_lower/stmt/mod.rs @@ -54,7 +54,9 @@ mod instance_property_writes; mod static_property_writes; mod property_array_writes; mod metadata_control; +mod nested_append; mod return_coercions; +mod repr_fixpoint; mod static_property_helpers; use statement_basics::*; @@ -89,15 +91,14 @@ pub(super) use array_write_storage::{ /// Lowers one AST statement into the current EIR insertion block. pub(crate) fn lower_stmt(ctx: &mut LoweringContext<'_, '_>, stmt: &Stmt) { crate::strict_php::with_source_mode(stmt.source_mode, || { - lower_stmt_in_current_source_mode(ctx, stmt); + if !ctx.builder.insertion_block_is_terminated() { + repr_fixpoint::lower_stmt_at_type_fixpoint(ctx, stmt); + } }); } -/// Lowers one statement after installing its physical source visibility profile. -fn lower_stmt_in_current_source_mode(ctx: &mut LoweringContext<'_, '_>, stmt: &Stmt) { - if ctx.builder.insertion_block_is_terminated() { - return; - } +/// Lowers one statement exactly once against the current local representations. +fn lower_stmt_once(ctx: &mut LoweringContext<'_, '_>, stmt: &Stmt) { lower_statement_concat_reset(ctx, stmt.span); match &stmt.kind { StmtKind::Echo(expr) => lower_echo(ctx, expr, stmt.span), @@ -184,7 +185,13 @@ fn lower_stmt_in_current_source_mode(ctx: &mut LoweringContext<'_, '_>, stmt: &S lower_include_once_guard(ctx, label, body, stmt.span); } StmtKind::Throw(expr) => lower_throw(ctx, expr), - StmtKind::Synthetic(body) => lower_block(ctx, body), + // Nested appends are parser-generated read/push/write-back groups. Fuse the recognized + // shape so a missing inner bucket is auto-vivified and a local bucket can be detached + // before mutation; every other synthetic group keeps ordinary block lowering. + StmtKind::Synthetic(body) => match nested_append::recognize(ctx, body) { + Some(group) => nested_append::lower(ctx, &group, stmt.span), + None => lower_block(ctx, body), + }, StmtKind::Try { try_body, catches, @@ -253,3 +260,12 @@ fn lower_stmt_in_current_source_mode(ctx: &mut LoweringContext<'_, '_>, stmt: &S } => lower_property_array_assign(ctx, object, property, index, value, stmt.span), } } + +/// Returns whether a local array slot can be converted at the current program point. +fn local_slot_is_convertible_here(ctx: &LoweringContext<'_, '_>, name: &str) -> bool { + repr_fixpoint::local_slot_kind_is_convertible(ctx, name) + && ctx + .local_slots + .get(name) + .is_some_and(|slot| ctx.slot_is_initialized(*slot)) +} diff --git a/src/ir_lower/stmt/nested_append.rs b/src/ir_lower/stmt/nested_append.rs new file mode 100644 index 0000000000..8b11b5a540 --- /dev/null +++ b/src/ir_lower/stmt/nested_append.rs @@ -0,0 +1,357 @@ +//! Purpose: +//! Recognizes the statement group a nested append (`$a[$k][] = $v`) desugars to, and lowers it +//! as a fused, auto-vivifying, in-place append instead of a read/copy/write-back. +//! +//! Called from: +//! - `crate::ir_lower::stmt::lower_stmt()`, on `StmtKind::Synthetic`. +//! +//! Key details: +//! - The parser eagerly desugars EVERY nested append, at parse time, into a +//! read / push / write-back triple wrapped in a `StmtKind::Synthetic` +//! (`crate::parser::stmt::assign::postfix::lower_nested_append_assignment`). Neither the +//! checker nor IR lowering ever sees a "nested append" node. That desugar has two defects +//! this module fixes, and it is the only place they can be fixed without a new AST node: +//! +//! 1. **It loses data.** The first push into any bucket is a *miss*: nothing auto-vivifies +//! the missing inner array the way PHP does. On a `Mixed` bucket the miss reads back a +//! boxed null and the append silently drops the value; on a concretely-typed bucket it +//! reads back the missing-key sentinel. `$g = []; $g["k"][] = 1;` printed `count() == 0`. +//! 2. **It is quadratic.** The read leaves the bucket owned twice (the container slot and the +//! temporary), so the push copy-on-write clones the whole bucket — O(length) per push, +//! O(n^2) over a growing bucket. `Op::SlotDetach` nulls the slot between the read and the +//! push, dropping the count back to one so the append mutates in place. +//! +//! - Recognition has two locks that PHP source cannot forge: the `StmtKind::Synthetic` wrapper +//! (no surface syntax) and the temporary's reserved `NESTED_APPEND_TEMP_PREFIX` (not a legal +//! PHP identifier). The prefix is what distinguishes this group from the `.=` / `+=` desugars, +//! which emit the same statement shapes from the same lowerer. +//! - Anything it does not recognize **fails open** to `lower_block`, i.e. to today's lowering, +//! bit for bit. That is deliberate: the scope gate below is narrow on purpose. + +use crate::ir::{IrHeapKind, IrType, Op}; +use crate::ir_lower::context::{LoweredValue, LoweringContext}; +use crate::ir_lower::expr::lower_expr; +use crate::parser::ast::{ + Expr, ExprKind, StaticReceiver, Stmt, StmtKind, NESTED_APPEND_TEMP_PREFIX, +}; +use crate::span::Span; +use crate::types::PhpType; + +/// A `StmtKind::Synthetic` body proven to be a nested append this module can fuse. +/// +/// `prefix` holds the stabilization assignments the parser hoists when the key expression is +/// not replayable (`$a[f()][] = 1` yields more than three statements), so the group is matched +/// as a *suffix*, never by a length check. +pub(super) struct NestedAppendGroup<'a> { + prefix: &'a [Stmt], + read: &'a Stmt, + push: &'a Stmt, + write_back: &'a Stmt, + base: BaseKind<'a>, + index: &'a Expr, +} +/// What the nested append's outer container is. +/// +/// The distinction is not cosmetic. `Op::SlotDetach` republishes the container pointer through +/// `source_load_local_slot`, which only resolves a LOCAL slot — `__rt_hash_set` may rehash the +/// table and hand back a new pointer, and on a property base that pointer would never make it back +/// into the property, leaving it stale. So a property base gets the auto-vivification (which is the +/// data-loss fix) but NOT the detach (which is the O(n^2) fix). Correctness first; the property +/// base stays quadratic until the detach can republish through a property store. +enum BaseKind<'a> { + Local(&'a str), + Property { + object: &'a Expr, + property: &'a str, + }, + StaticProperty { + receiver: &'a StaticReceiver, + property: &'a str, + }, +} + +/// Returns the nested-append group a synthetic body encodes, or `None` to fall back to +/// today's lowering. +/// +/// The scope gate is deliberately narrow: a plain variable base whose checker type is an indexed +/// or associative array. Property bases and static-property bases are gated after loading their +/// IR value. Other shapes keep the existing read/copy/write-back path. +pub(super) fn recognize<'a>( + ctx: &LoweringContext<'_, '_>, + body: &'a [Stmt], +) -> Option> { + if body.len() < 3 { + return None; + } + let split = body.len() - 3; + let (prefix, triple) = body.split_at(split); + + let (temp, base, index) = match &triple[0].kind { + StmtKind::Assign { name, value } if name.starts_with(NESTED_APPEND_TEMP_PREFIX) => { + match &value.kind { + ExprKind::ArrayAccess { array, index } => match &array.kind { + ExprKind::Variable(base) => { + (name.as_str(), BaseKind::Local(base.as_str()), index.as_ref()) + } + ExprKind::PropertyAccess { object, property } => ( + name.as_str(), + BaseKind::Property { + object: object.as_ref(), + property: property.as_str(), + }, + index.as_ref(), + ), + ExprKind::StaticPropertyAccess { receiver, property } => ( + name.as_str(), + BaseKind::StaticProperty { + receiver, + property: property.as_str(), + }, + index.as_ref(), + ), + _ => return None, + }, + _ => return None, + } + } + _ => return None, + }; + + match &triple[1].kind { + StmtKind::ArrayPush { array, .. } if array == temp => {} + _ => return None, + } + + // The write-back must target the SAME container the read came from, and hand back the very + // temporary the push mutated. Both were built by the parser from one stabilized target, so + // matching the shape and the names is enough — the `Synthetic` wrapper and the reserved temp + // prefix already prove the provenance. + match (&triple[2].kind, &base) { + (StmtKind::ArrayAssign { array, value, .. }, BaseKind::Local(base_name)) + if array == base_name => + { + match &value.kind { + ExprKind::Variable(name) if name == temp => {} + _ => return None, + } + } + ( + StmtKind::PropertyArrayAssign { + property, value, .. + }, + BaseKind::Property { + property: base_property, + .. + }, + ) if property == base_property => match &value.kind { + ExprKind::Variable(name) if name == temp => {} + _ => return None, + }, + ( + StmtKind::StaticPropertyArrayAssign { + property, value, .. + }, + BaseKind::StaticProperty { + property: base_property, + .. + }, + ) if property == base_property => match &value.kind { + ExprKind::Variable(name) if name == temp => {} + _ => return None, + }, + _ => return None, + } + + // A local container must be an indexed- or associative-array local the checker knows about; + // anything else falls open to ordinary lowering. A property container is gated at lowering + // time instead, on the IR type of the loaded property. + if let BaseKind::Local(name) = base { + if !ctx.has_local_slot(name) + || !matches!(ctx.local_type(name), PhpType::Array(_) | PhpType::AssocArray { .. }) + { + return None; + } + } + + Some(NestedAppendGroup { + prefix, + read: &triple[0], + push: &triple[1], + write_back: &triple[2], + base, + index, + }) +} + +/// Lowers a recognized nested append: vivify if missing, then read, detach, push in place, +/// write back. +/// +/// Only the *vivification* is conditional. Everything after it is the very straight-line +/// sequence the parser already emits, lowered by the ordinary statement lowerings — so the +/// fused path inherits every existing decision about element typing, Mixed boxing, and the +/// hash-versus-indexed refcount asymmetry (`__rt_hash_set` *consumes* the value it stores; +/// `__rt_array_set_refcounted` *retains* it). Re-deriving any of that by hand would leak on one +/// side and double-free on the other. +/// +/// The vivification writes an empty array into the slot through the same `StmtKind::ArrayAssign` +/// lowering the write-back uses, rather than assigning `[]` straight into the append temporary. +/// That is not a stylistic choice: the temporary's checker type is the container's *value* type +/// (typically `Mixed`), so assigning a bare `Array(Never)` literal into it bypasses the boxing +/// the container's storage expects. The bucket then looked fine until it outgrew its initial +/// capacity, at which point growing it read the malformed header and segfaulted. +pub(super) fn lower(ctx: &mut LoweringContext<'_, '_>, group: &NestedAppendGroup<'_>, span: Span) { + for stmt in group.prefix { + super::lower_stmt(ctx, stmt); + } + + // Load the container and probe the key. For a local this is a `LoadLocal`; for a property it is + // an ordinary property read, which is pure and can be replayed. + let container = load_container(ctx, group, span); + let isset_op = match container.ir_type { + IrType::Heap(IrHeapKind::Hash) => Op::HashIsset, + IrType::Heap(IrHeapKind::Array) => Op::ArrayIsset, + // Anything else (a `Mixed` property, an object) is out of scope: fall open to today's + // lowering, which is what the parser's desugar already produces. + _ => { + super::lower_stmt(ctx, group.read); + super::lower_stmt(ctx, group.push); + super::lower_stmt(ctx, group.write_back); + return; + } + }; + let key = lower_expr(ctx, group.index); + let present = ctx.emit_value( + isset_op, + vec![container.value, key.value], + None, + PhpType::Bool, + isset_op.default_effects(), + Some(span), + ); + if matches!(group.base, BaseKind::Local(_)) { + crate::ir_lower::ownership::release_if_owned(ctx, container, Some(span)); + } + crate::ir_lower::ownership::release_if_owned(ctx, key, Some(span)); + + // Snapshot the definitely-initialized locals before the split and restore them at the head of + // the arm, exactly as `lower_if_chain` does; the vivify arm initializes nothing new, so the + // merge simply inherits the pre-split set. + let split_initialized = ctx.initialized_slots_snapshot(); + let vivify_block = ctx.builder.create_named_block("napp.vivify", Vec::new()); + let body_block = ctx.builder.create_named_block("napp.body", Vec::new()); + ctx.builder.terminate(crate::ir::Terminator::CondBr { + cond: present.value, + then_target: body_block, + then_args: Vec::new(), + else_target: vivify_block, + else_args: Vec::new(), + }); + + // -- vivify: PHP creates the missing inner array; nothing else does -- + ctx.builder.position_at_end(vivify_block); + ctx.restore_initialized_slots(split_initialized.clone()); + let vivify = vivify_stmt(group, span); + super::lower_stmt(ctx, &vivify); + ctx.builder.terminate(crate::ir::Terminator::Br { + target: body_block, + args: Vec::new(), + }); + + // -- body: the slot now certainly holds an array -- + ctx.builder.position_at_end(body_block); + ctx.restore_initialized_slots(split_initialized); + super::lower_stmt(ctx, group.read); + + // Hand the container slot's reference to the temporary, so the push below sees a uniquely-owned + // bucket and mutates it in place instead of copy-on-write cloning it — O(n^2) -> O(n). This + // cannot free the bucket: the read above already took a reference, so the count it drops is at + // least two. + // + // ONLY for a local base. `Op::SlotDetach` republishes the (possibly rehashed) container pointer + // through `source_load_local_slot`, which resolves a local slot and nothing else; on a property + // base the new pointer would never reach the property and it would be left stale. A property + // base therefore keeps the auto-vivification and stays quadratic. + if let BaseKind::Local(name) = &group.base { + if matches!(ctx.local_type(name), PhpType::Array(_) | PhpType::AssocArray { .. }) { + // Re-load the container and key: they were emitted in a predecessor block, and the + // vivification may have republished a grown or copy-on-write-split container pointer into + // the local. A `LoadLocal` is pure, and the key is either replayable or already hoisted into + // a prefix temporary, so neither is evaluated twice observably. + let container = ctx.load_local(name, Some(span)); + let key = lower_expr(ctx, group.index); + ctx.emit_void( + Op::SlotDetach, + vec![container.value, key.value], + None, + Op::SlotDetach.default_effects(), + Some(span), + ); + } + } + + super::lower_stmt(ctx, group.push); + super::lower_stmt(ctx, group.write_back); +} + +/// Loads the nested append's outer container as a value. +fn load_container( + ctx: &mut LoweringContext<'_, '_>, + group: &NestedAppendGroup<'_>, + span: Span, +) -> LoweredValue { + match &group.base { + BaseKind::Local(name) => ctx.load_local(name, Some(span)), + BaseKind::Property { object, property } => { + let access = Expr::new( + ExprKind::PropertyAccess { + object: Box::new((*object).clone()), + property: (*property).to_string(), + }, + span, + ); + lower_expr(ctx, &access) + } + BaseKind::StaticProperty { receiver, property } => { + let access = Expr::new( + ExprKind::StaticPropertyAccess { + receiver: (*receiver).clone(), + property: (*property).to_string(), + }, + span, + ); + lower_expr(ctx, &access) + } + } +} + +/// Builds the statement that auto-vivifies the missing inner array. +/// +/// It writes an empty array into the container through the SAME write-back lowering the group's own +/// write-back uses. That is not a stylistic choice: the append temporary's checker type is the +/// container's VALUE type (typically `Mixed`), so assigning a bare `Array(Never)` literal straight +/// into it would bypass the boxing the container's storage expects. The bucket then looked fine +/// until it outgrew its initial capacity, at which point growing it read a malformed header and +/// segfaulted. +fn vivify_stmt(group: &NestedAppendGroup<'_>, span: Span) -> Stmt { + let empty = Expr::new(ExprKind::ArrayLiteral(Vec::new()), span); + let kind = match &group.base { + BaseKind::Local(name) => StmtKind::ArrayAssign { + array: (*name).to_string(), + index: group.index.clone(), + value: empty, + }, + BaseKind::Property { object, property } => StmtKind::PropertyArrayAssign { + object: Box::new((*object).clone()), + property: (*property).to_string(), + index: group.index.clone(), + value: empty, + }, + BaseKind::StaticProperty { receiver, property } => StmtKind::StaticPropertyArrayAssign { + receiver: (*receiver).clone(), + property: (*property).to_string(), + index: group.index.clone(), + value: empty, + }, + }; + Stmt::new(kind, span) +} diff --git a/src/ir_lower/stmt/nested_array_writes.rs b/src/ir_lower/stmt/nested_array_writes.rs index 119cfc75ed..a415dbb959 100644 --- a/src/ir_lower/stmt/nested_array_writes.rs +++ b/src/ir_lower/stmt/nested_array_writes.rs @@ -158,11 +158,11 @@ pub(super) fn lower_local_parent_fetch_for_write( // The element now exists: the in-bounds read returns the // STORED cell (retained) without an undefined-key warning. let cell = ctx.emit_value( - Op::ArrayGet, + Op::ArrayGetForWrite, vec![ensured.value, key.value], None, PhpType::Mixed, - Op::ArrayGet.default_effects(), + Op::ArrayGetForWrite.default_effects(), Some(span), ); Some(cell) @@ -203,7 +203,7 @@ pub(super) fn lower_local_parent_fetch_for_write( /// Ensures a hash element exists for a nested write parent, stores the /// possibly reallocated hash back into the local (the previous owner was /// already released by `prepare_mutated_local_owner`), and re-reads the -/// stored cell (retained by `Op::HashGet`) as the parent of the leaf write. +/// stored cell (retained by `Op::HashGetForWrite`) as the parent of the leaf write. pub(super) fn lower_hash_parent_fetch_for_write( ctx: &mut LoweringContext<'_, '_>, name: &str, @@ -223,12 +223,11 @@ pub(super) fn lower_hash_parent_fetch_for_write( ); ctx.store_prepared_mutated_local(name, ensured, assoc_ty, Some(span)); ctx.emit_value( - Op::HashGet, + Op::HashGetForWrite, vec![ensured.value, key.value], None, PhpType::Mixed, - Op::HashGet.default_effects(), + Op::HashGetForWrite.default_effects(), Some(span), ) } - diff --git a/src/ir_lower/stmt/repr_fixpoint.rs b/src/ir_lower/stmt/repr_fixpoint.rs new file mode 100644 index 0000000000..9e4972fee4 --- /dev/null +++ b/src/ir_lower/stmt/repr_fixpoint.rs @@ -0,0 +1,809 @@ +//! Purpose: +//! Lowers every statement at an array-representation FIXED POINT, so no instruction is ever +//! compiled against a runtime array layout that another instruction in the same statement replaces. +//! +//! Called from: +//! - `crate::ir_lower::stmt::lower_stmt`, for every statement of every body. +//! +//! Key details: +//! - Two ops rewrite a local array's storage in place: `Op::ArrayToMixed` (boxed element slots) and +//! `Op::ArrayToHash` (packed vector -> hash table). Both are emitted MID-lowering, from a decision +//! that depends on already-lowered value types, so the AST cannot predict them. +//! - The statement is the smallest region that both DOMINATES and PRECEDES every op it contains, so +//! it is the smallest region a conversion can be hoisted to without corrupting an operand that was +//! already emitted (see `lower_stmt_at_type_fixpoint`). + +use std::collections::HashSet; + +use crate::ir::{LocalKind, Op}; +use crate::ir_lower::context::LoweringContext; +use crate::parser::ast::{ + BinOp, CallableTarget, CatchClause, Expr, ExprKind, InstanceOfTarget, Stmt, StmtKind, +}; +use crate::span::Span; +use crate::types::{array_storage_conversion, PhpType}; + +use super::{local_slot_is_convertible_here, lower_stmt_once}; + +/// Lowers one statement against local types that its own conversions cannot invalidate. +/// +/// An op is lowered against the local-type environment holding AT THAT POINT. A conversion +/// (`Op::ArrayToMixed`, `Op::ArrayToHash`) re-types a local mid-lowering AND, at runtime, rewrites +/// the array's storage. Any op lowered against the OLD representation but EXECUTED after the +/// conversion then misinterprets that storage: boxed cell pointers read as raw scalars (SIGSEGV), or +/// packed indices read out of a hash table (silent data loss). The conversion is emitted where the +/// write happens, but it is reachable — a `switch` case, a `catch` handler, a ternary arm, a loop +/// back edge — from code that was lowered before it. +/// +/// The fix is to CANONICALIZE: discover what the statement converts, then convert it up front, at a +/// point that both DOMINATES and PRECEDES every op inside, and lower the statement again against +/// that. Only the statement is small enough to be that point and large enough to cover every +/// construct: no operand of a statement precedes the statement's entry, so re-lowering it re-lowers +/// the operands too. +/// +/// Hoisting to a construct's own entry (a `switch`'s, a `match`'s) instead REGRESSES correct +/// programs: in `h($m, match ($c) { 1 => $m[0] = "s", default => "d" })` argument 0 is emitted +/// BEFORE the match is lowered, holds the array pointer with no refcount protection, and +/// `__rt_array_to_mixed` rewrites the slots IN PLACE at refcount 1 — so running the conversion on +/// the `default` path (where today it does not run) would corrupt an argument that is already +/// correct. Hoisting to the STATEMENT is safe precisely because argument 0 is then re-lowered too. +/// +/// The discovery pass is what makes this cost anything, so it is gated three times: no convertible +/// array local in scope, or a statement that cannot syntactically touch one, skips it entirely; and +/// a statement lowered INSIDE a discovery pass runs no discovery of its own (`is_speculating`), +/// which keeps the total lowering cost linear in nesting depth instead of exponential. +pub(super) fn lower_stmt_at_type_fixpoint(ctx: &mut LoweringContext<'_, '_>, stmt: &Stmt) { + lower_region_at_type_fixpoint( + ctx, + stmt.span, + |candidates| may_convert_array_local(stmt, candidates), + |ctx| lower_stmt_once(ctx, stmt), + ); +} +/// Lowers the post-init region of a `for` loop at the same fixed point. +/// +/// `for ($a = [1, 2]; ...; ...) { $a[0] = "s"; }` needs a region whose entry is BELOW the init: at +/// the `for` statement's own entry `$a` does not exist yet, so it is not a candidate there and its +/// conversion cannot be hoisted. Below the init it is an ordinary loop-carried array, and the +/// conversion lands in the preheader like any other. +pub(super) fn lower_for_body_at_type_fixpoint( + ctx: &mut LoweringContext<'_, '_>, + span: Span, + condition: Option<&Expr>, + update: Option<&Stmt>, + body: &[Stmt], + lower_once: impl Fn(&mut LoweringContext<'_, '_>), +) { + lower_region_at_type_fixpoint( + ctx, + span, + |candidates| { + // The for-body region is a LOOP body, so the back edge is the hiding construct — it + // re-runs body ops lowered above a conversion regardless of what the body contains. The + // `has_hiding` narrowing that skips straight-line STATEMENTS must not apply here, or a + // loop with a straight-line widening body (`for ($a=[..];..) { echo $a[0]; $a[0]="s"; }`) + // would never pre-widen `$a` in the preheader. + let mut scan = ConversionScan::new_hiding_region(candidates); + if let Some(condition) = condition { + scan.expr(condition); + } + if let Some(update) = update { + scan.stmt(update); + } + scan.block(body); + scan.done() + }, + lower_once, + ); +} + +/// Lowers one region against local types that its own conversions cannot invalidate. +fn lower_region_at_type_fixpoint( + ctx: &mut LoweringContext<'_, '_>, + span: Span, + may_convert: impl FnOnce(&[String]) -> bool, + lower_once: impl Fn(&mut LoweringContext<'_, '_>), +) { + if ctx.is_speculating() { + lower_once(ctx); + return; + } + let candidates = convertible_array_locals(ctx); + if candidates.is_empty() || !may_convert(&candidates) { + lower_once(ctx); + return; + } + + // Discover the conversions by LOWERING the region — the decision is a pure function of + // already-lowered value types, so re-deriving it from the AST would duplicate (and eventually + // desynchronize from) the lowering that makes it — then throw that lowering away completely. + // The records are read BEFORE the rollback discards them, but they are matched against the + // region's ENTRY types only AFTER it, in `canonicalize_array_locals`: read now, the entry types + // are the speculation's EXIT types, which already carry the very conversions being looked for. + let snapshot = ctx.snapshot(); + ctx.forget_array_conversions(&candidates); + let outer = ctx.set_speculating(true); + lower_once(ctx); + ctx.set_speculating(outer); + let conversions = discovered_conversions(ctx, &candidates); + ctx.restore(snapshot); + + // Re-lower unconditionally, even with nothing to convert: the discovery pass suppressed the + // fixed point of every NESTED region, so its output is not a lowering anyone may keep. A local + // first assigned inside this region is not a candidate here and is canonicalized by its own + // nested region during this second, non-speculative pass. + canonicalize_array_locals(ctx, &conversions, span); + lower_once(ctx); +} + +/// Returns the locals whose array storage a statement could still convert, in a deterministic order. +/// +/// A local already held in hash storage is excluded: no op converts a hash back, so its +/// representation is final. Everything else that is an indexed array — including an `Array(Mixed)`, +/// which a string-keyed write still promotes to a hash — can move. +fn convertible_array_locals(ctx: &LoweringContext<'_, '_>) -> Vec { + let mut names = ctx + .local_types + .iter() + .filter(|(name, php_type)| { + matches!(php_type.codegen_repr(), PhpType::Array(_)) + && local_slot_is_convertible_here(ctx, name) + }) + .map(|(name, _)| name.clone()) + .collect::>(); + // `local_types` is a HashMap: sort so the conversion instructions are emitted in a stable order + // regardless of hash seed. + names.sort(); + names +} + +/// Returns what the just-lowered region converted each candidate to, as `(local, representation)`. +/// +/// The candidates come from the type environment and are filtered by the conversion record, not the +/// other way round: the record is function-scoped, so a set DIFFERENCE against a pre-region snapshot +/// would miss a local converted earlier in the function, rebound to a fresh concrete array since, +/// and converted again here. `forget_array_conversions` is what keeps that over-approximation from +/// hoisting conversions this region does not actually perform. +fn discovered_conversions( + ctx: &LoweringContext<'_, '_>, + candidates: &[String], +) -> Vec<(String, PhpType)> { + candidates + .iter() + .filter_map(|name| Some((name.clone(), ctx.array_conversion(name)?.clone()))) + .collect() +} + +/// Returns the op that converts a local from its region-entry representation to `target`. +/// +/// `None` means the storage is already in the target representation (or in one no op moves it out +/// of), and hoisting anything would be worse than hoisting nothing: emitting `Op::ArrayToMixed` +/// where a hash is what the region actually builds would box the slots of an array the region then +/// re-reads as a hash. The decision is `array_storage_conversion` — the SAME predicate the checker +/// applies to the type environment — so the element type a callee is compiled for cannot drift from +/// the one the caller actually passes. +fn conversion_op(entry: &PhpType, target: &PhpType) -> Option { + match array_storage_conversion(Some(entry), target)? { + PhpType::Array(_) => Some(Op::ArrayToMixed), + PhpType::AssocArray { .. } => Some(Op::ArrayToHash), + _ => None, + } +} + +/// Converts local arrays to the representation the region ahead was lowered against. +/// +/// This is the same pair of conversions the element writes themselves perform +/// (`prepare_indexed_array_local_set` and `lower_string_key_array_promotion`), emitted where control +/// flow needs them instead of where the write happens, and with the same ownership pairing: the +/// helpers take the loaded array as an owned reference and `store_mutated_local` puts the result +/// back without re-acquiring it. +/// +/// Both helpers are idempotent on an already-converted array — `__rt_array_to_mixed` re-stamps a +/// Mixed array without re-boxing it, and `Op::ArrayToHash` reuses a hash payload as-is — so a +/// canonicalization inside an outer loop stays correct on every iteration. +fn canonicalize_array_locals( + ctx: &mut LoweringContext<'_, '_>, + conversions: &[(String, PhpType)], + span: Span, +) { + for (name, target) in conversions { + let Some(op) = conversion_op(&ctx.local_type(name), target) else { + continue; + }; + let array = ctx.load_local(name, Some(span)); + let converted = ctx.emit_value( + op, + vec![array.value], + None, + target.clone(), + op.default_effects(), + Some(span), + ); + ctx.store_mutated_local(name, converted, target.clone(), Some(span)); + } +} + +/// Returns true when a statement could possibly convert the storage of one of `candidates` ON A +/// PATH THAT AN ALREADY-LOWERED OP OF THE SAME STATEMENT DEPENDS ON. +/// +/// Purely syntactic and deliberately coarse: it never re-derives the lowering's decision (which +/// depends on types the AST does not carry), it only proves the ABSENCE of a hazard. THREE facts +/// have to hold together: +/// +/// - it names a candidate, and +/// - it contains a node that can mutate a local — because a conversion is performed BY a mutation ON +/// a named local (an assignment, any call — a by-ref array param converts its arg, `unset($a[$k])` +/// promotes to a hash, `array_push` widens — all count), and +/// - it contains a CONVERSION-HIDING construct: any BRANCHING — an `if`/`elseif`/`else`, a loop +/// (back edge re-runs ops lowered above a conversion), a `switch` (fall-through gives a case two +/// predecessors), a `try` (the handler is reachable from mid-body) — or a conditionally-evaluated +/// expression (`match`, ternary, `?:`, `??`, `&&`, `||`) whose arm can convert an array a SIBLING +/// operand already loaded. +/// +/// The third fact is what makes a plain STRAIGHT-LINE statement cheap and is the ONLY narrowing that +/// is provably sound: `$a[0] = "s";` or `h($a);` with no branching converts inline, and every op +/// after it is lowered after it, so nothing it converts is read through a stale view. A plain `if` +/// is NOT narrowed away — its own arm-tail join reconciles only the Array->Mixed axis, so an +/// Array->Hash conversion in one arm must still be canonicalized at the `if`'s entry (a call or loop +/// after the `if` reads the local through a single lowering the per-path continuation does not always +/// duplicate). A false positive costs one speculative lowering; a false negative is a silent +/// miscompile, so the hiding set is deliberately broad — every branching form is in it. +/// +/// A local reached only through an ALIAS (`$r = &$m`) is not covered, and does not need to be: +/// `local_slot_is_convertible_here` excludes ref-bound and global-storage locals from the candidates +/// in the first place. +fn may_convert_array_local(stmt: &Stmt, candidates: &[String]) -> bool { + let mut scan = ConversionScan::new(candidates); + scan.stmt(stmt); + scan.done() +} + +/// Accumulates the three facts `may_convert_array_local` needs from one AST subtree. +struct ConversionScan<'a> { + candidates: HashSet<&'a str>, + names_candidate: bool, + mutates: bool, + has_hiding: bool, +} + +impl<'a> ConversionScan<'a> { + /// Starts a scan over the locals a region could still convert. + fn new(candidates: &'a [String]) -> Self { + Self { + candidates: candidates.iter().map(String::as_str).collect(), + names_candidate: false, + mutates: false, + has_hiding: false, + } + } + + /// Starts a scan for a region that is ALREADY a hiding context (a loop body): the `has_hiding` + /// fact is pre-satisfied, so the region fixpoints on names + mutation alone, exactly like the + /// pre-narrowing gate. Used for the `for`-body region, whose enclosing loop the body scan does + /// not itself contain. + fn new_hiding_region(candidates: &'a [String]) -> Self { + let mut scan = Self::new(candidates); + scan.has_hiding = true; + scan + } + + /// Returns true once all three facts hold and the rest of the subtree cannot change the answer. + fn done(&self) -> bool { + self.names_candidate && self.mutates && self.has_hiding + } + + /// Records a reference to a local by name. + fn name(&mut self, name: &str) { + if self.candidates.contains(name) { + self.names_candidate = true; + } + } + + /// Records a node that can mutate a local's value or type. + fn mutation(&mut self) { + self.mutates = true; + } + + /// Records a construct that can hide a conversion from an already-lowered, later-executing op. + fn hiding(&mut self) { + self.has_hiding = true; + } + + /// Walks a block of statements. + fn block(&mut self, body: &[Stmt]) { + for stmt in body { + if self.done() { + return; + } + self.stmt(stmt); + } + } + + /// Walks one statement. The match is exhaustive so a new `StmtKind` cannot silently default to + /// "cannot convert anything", which would be a miscompile rather than a missed optimization. + fn stmt(&mut self, stmt: &Stmt) { + if self.done() { + return; + } + match &stmt.kind { + StmtKind::Assign { name, value } => { + self.mutation(); + self.name(name); + self.expr(value); + } + StmtKind::RefAssign { target, source } => { + self.mutation(); + self.name(target); + self.expr(source); + } + StmtKind::ArrayAssign { array, index, value } => { + self.mutation(); + self.name(array); + self.expr(index); + self.expr(value); + } + StmtKind::NestedArrayAssign { target, value } => { + self.mutation(); + self.expr(target); + self.expr(value); + } + StmtKind::ArrayPush { array, value } => { + self.mutation(); + self.name(array); + self.expr(value); + } + StmtKind::TypedAssign { type_expr: _, name, value } => { + self.mutation(); + self.name(name); + self.expr(value); + } + StmtKind::ListUnpack { vars, value } => { + self.mutation(); + for var in vars { + self.name(var); + } + self.expr(value); + } + StmtKind::Global { vars } => { + self.mutation(); + for var in vars { + self.name(var); + } + } + StmtKind::StaticVar { name, init } => { + self.mutation(); + self.name(name); + self.expr(init); + } + StmtKind::Foreach { array, key_var, value_var, value_by_ref: _, body } => { + // A loop back edge re-runs body ops lowered above a conversion point. + self.hiding(); + self.mutation(); + self.expr(array); + if let Some(key_var) = key_var { + self.name(key_var); + } + self.name(value_var); + self.block(body); + } + StmtKind::PropertyAssign { object, property: _, value } => { + self.mutation(); + self.expr(object); + self.expr(value); + } + StmtKind::PropertyArrayPush { object, property: _, value } => { + self.mutation(); + self.expr(object); + self.expr(value); + } + StmtKind::PropertyArrayAssign { object, property: _, index, value } => { + self.mutation(); + self.expr(object); + self.expr(index); + self.expr(value); + } + StmtKind::StaticPropertyAssign { receiver: _, property: _, value } => { + self.mutation(); + self.expr(value); + } + StmtKind::StaticPropertyArrayPush { receiver: _, property: _, value } => { + self.mutation(); + self.expr(value); + } + StmtKind::StaticPropertyArrayAssign { receiver: _, property: _, index, value } => { + self.mutation(); + self.expr(index); + self.expr(value); + } + StmtKind::Include { path, once: _, required: _ } => { + // A residual include runs arbitrary code in the caller's scope; treat conservatively. + self.hiding(); + self.mutation(); + self.expr(path); + } + StmtKind::IncludeOnceGuard { label: _, body } => { + // A once-guard executes its body conditionally. + self.hiding(); + self.mutation(); + self.block(body); + } + // A synthetic block (e.g. the nested-append desugaring) is straight-line in itself; any + // branching it contains is discovered by walking its body. + StmtKind::Synthetic(body) => { + self.mutation(); + self.block(body); + } + StmtKind::Echo(expr) | StmtKind::Throw(expr) | StmtKind::ExprStmt(expr) => { + self.expr(expr); + } + StmtKind::ConstDecl { name: _, value } => self.expr(value), + StmtKind::Return(value) => { + if let Some(value) = value { + self.expr(value); + } + } + StmtKind::If { condition, then_body, elseif_clauses, else_body } => { + // The arm-tail join reconciles the merge only on the Array->Mixed axis + // (`join_arm_types`), not Array->Hash, and a call or a loop after the `if` reads the + // local through ONE lowering that a per-path continuation does not always duplicate + // (e.g. `if ($c) { $m["k"]=1; } h($m);`). Entry canonicalization is what fixes that, + // so an `if` chain is a hiding region. + self.hiding(); + self.expr(condition); + self.block(then_body); + for (condition, body) in elseif_clauses { + self.expr(condition); + self.block(body); + } + if let Some(else_body) = else_body { + self.block(else_body); + } + } + StmtKind::IfDef { symbol: _, then_body, else_body } => { + self.hiding(); + self.block(then_body); + if let Some(else_body) = else_body { + self.block(else_body); + } + } + StmtKind::While { condition, body } | StmtKind::DoWhile { body, condition } => { + // A loop back edge re-runs body ops lowered above a conversion point. + self.hiding(); + self.expr(condition); + self.block(body); + } + StmtKind::For { init, condition, update, body } => { + // The init runs before the loop, but the condition/update/body re-run on the back + // edge, so the whole statement is a hiding region. + self.hiding(); + if let Some(init) = init { + self.stmt(init); + } + if let Some(condition) = condition { + self.expr(condition); + } + if let Some(update) = update { + self.stmt(update); + } + self.block(body); + } + StmtKind::Switch { subject, cases, default } => { + // PHP fall-through gives a case two predecessors; a case body is lowered against the + // previous body's exit env but is also entered directly. + self.hiding(); + self.expr(subject); + for (patterns, body) in cases { + for pattern in patterns { + self.expr(pattern); + } + self.block(body); + } + if let Some(default) = default { + self.block(default); + } + } + StmtKind::Try { try_body, catches, finally_body } => { + // A handler is reachable from every point in the try, including above a conversion. + self.hiding(); + self.block(try_body); + for CatchClause { exception_types: _, variable, body } in catches { + if let Some(variable) = variable { + self.name(variable); + } + self.block(body); + } + if let Some(finally_body) = finally_body { + self.block(finally_body); + } + } + StmtKind::NamespaceBlock { name: _, body } => self.block(body), + // Leaves and declarations: a declaration's body is lowered as its own function, with its + // own locals, so it cannot convert one of ours. + StmtKind::Break(_) + | StmtKind::Continue(_) + | StmtKind::IncludeOnceMark { .. } + | StmtKind::NamespaceDecl { .. } + | StmtKind::UseDecl { .. } + | StmtKind::FunctionDecl { .. } + | StmtKind::FunctionVariantGroup { .. } + | StmtKind::FunctionVariantMark { .. } + | StmtKind::ClassDecl { .. } + | StmtKind::EnumDecl { .. } + | StmtKind::PackedClassDecl { .. } + | StmtKind::InterfaceDecl { .. } + | StmtKind::TraitDecl { .. } + | StmtKind::ExternFunctionDecl { .. } + | StmtKind::ExternClassDecl { .. } + | StmtKind::ExternGlobalDecl { .. } => {} + } + } + + /// Walks one expression. Exhaustive for the same reason as `stmt`. + fn expr(&mut self, expr: &Expr) { + if self.done() { + return; + } + match &expr.kind { + ExprKind::Variable(name) => self.name(name), + ExprKind::PreIncrement(name) + | ExprKind::PostIncrement(name) + | ExprKind::PreDecrement(name) + | ExprKind::PostDecrement(name) => { + self.mutation(); + self.name(name); + } + ExprKind::Assignment { + target, + value, + result_target, + prelude, + conditional_value_temp, + } => { + // A conditional assignment (`??=`, `?:` desugaring) evaluates its value only on one + // path, so a conversion in it is path-dependent. + if conditional_value_temp.is_some() { + self.hiding(); + } + self.mutation(); + self.expr(target); + self.expr(value); + if let Some(result_target) = result_target { + self.expr(result_target); + } + self.block(prelude); + if let Some(temp) = conditional_value_temp { + self.name(temp); + } + } + ExprKind::FunctionCall { name: _, args } => { + self.mutation(); + self.exprs(args); + } + ExprKind::MethodCall { object, method: _, args } => { + self.mutation(); + self.expr(object); + self.exprs(args); + } + ExprKind::NullsafeMethodCall { object, method: _, args } => { + // `?->` evaluates the arguments only when the receiver is non-null: conditional. + self.hiding(); + self.mutation(); + self.expr(object); + self.exprs(args); + } + ExprKind::NullsafeDynamicMethodCall { object, method, args } => { + // Both the dynamic method name and arguments are skipped for a null receiver. + self.hiding(); + self.mutation(); + self.expr(object); + self.expr(method); + self.exprs(args); + } + ExprKind::StaticMethodCall { receiver: _, method: _, args } + | ExprKind::NewScopedObject { receiver: _, args } => { + self.mutation(); + self.exprs(args); + } + ExprKind::ClosureCall { var, args } => { + self.mutation(); + self.name(var); + self.exprs(args); + } + ExprKind::ExprCall { callee, args } => { + self.mutation(); + self.expr(callee); + self.exprs(args); + } + ExprKind::Pipe { value, callable } => { + self.mutation(); + self.expr(value); + self.expr(callable); + } + ExprKind::NewObject { class_name: _, args } => { + self.mutation(); + self.exprs(args); + } + ExprKind::NewDynamic { name_expr, args } => { + self.mutation(); + self.expr(name_expr); + self.exprs(args); + } + ExprKind::NewDynamicObject { + class_name, + fallback_class: _, + required_parent: _, + args, + } => { + self.mutation(); + self.expr(class_name); + self.exprs(args); + } + ExprKind::IncludeValue { path, once: _, required: _ } => { + // An include in expression position runs arbitrary code; treat conservatively. + self.hiding(); + self.mutation(); + self.expr(path); + } + ExprKind::Yield { key, value } => { + self.mutation(); + if let Some(key) = key { + self.expr(key); + } + if let Some(value) = value { + self.expr(value); + } + } + ExprKind::YieldFrom(value) => { + self.mutation(); + self.expr(value); + } + ExprKind::Clone(value) => { + // Cloning can execute a user-defined `__clone` method. + self.mutation(); + self.expr(value); + } + ExprKind::Closure { + params, + variadic: _, + variadic_type: _, + return_type: _, + body, + is_arrow: _, + is_static: _, + by_ref_return: _, + variadic_by_ref: _, + captures, + capture_refs, + } => { + // The body is lowered as its own function against its own locals; only the capture + // list touches ours, and a by-reference capture makes the local ref-bound (never a + // candidate). The default-value expressions are still ours. + for (_, _, default, _) in params { + if let Some(default) = default { + self.expr(default); + } + } + self.block(body); + for capture in captures.iter().chain(capture_refs) { + self.name(capture); + } + } + ExprKind::BinaryOp { left, op, right } => { + // `&&`/`||` evaluate the right operand only conditionally, so a conversion in it is + // as path-dependent as a ternary arm; other binary ops evaluate both operands. + if matches!(op, BinOp::And | BinOp::Or) { + self.hiding(); + } + self.expr(left); + self.expr(right); + } + ExprKind::InstanceOf { value, target } => { + self.expr(value); + if let InstanceOfTarget::Expr(target) = target { + self.expr(target); + } + } + ExprKind::Negate(inner) + | ExprKind::Not(inner) + | ExprKind::BitNot(inner) + | ExprKind::Throw(inner) + | ExprKind::ErrorSuppress(inner) + | ExprKind::Print(inner) + | ExprKind::Spread(inner) + | ExprKind::Cast { target: _, expr: inner } + | ExprKind::PtrCast { target_type: _, expr: inner } + | ExprKind::NamedArg { name: _, value: inner } => self.expr(inner), + ExprKind::NullCoalesce { value, default } | ExprKind::ShortTernary { value, default } => { + // The default is evaluated only when the value is null/absent — conditional. + self.hiding(); + self.expr(value); + self.expr(default); + } + ExprKind::Ternary { condition, then_expr, else_expr } => { + self.hiding(); + self.expr(condition); + self.expr(then_expr); + self.expr(else_expr); + } + ExprKind::Match { subject, arms, default } => { + self.hiding(); + self.expr(subject); + for (patterns, body) in arms { + self.exprs(patterns); + self.expr(body); + } + if let Some(default) = default { + self.expr(default); + } + } + ExprKind::ArrayLiteral(items) => self.exprs(items), + ExprKind::ArrayLiteralAssoc(items) => { + for (key, value) in items { + self.expr(key); + self.expr(value); + } + } + ExprKind::ArrayAccess { array, index } => { + self.expr(array); + self.expr(index); + } + // A nullsafe property READ has no convertible sub-expression (a static property name), + // so it is not a hiding construct even though the read itself is conditional. + ExprKind::PropertyAccess { object, property: _ } + | ExprKind::NullsafePropertyAccess { object, property: _ } + | ExprKind::ObjectClassName { object } => self.expr(object), + ExprKind::DynamicPropertyAccess { object, property } => { + self.expr(object); + self.expr(property); + } + ExprKind::NullsafeDynamicPropertyAccess { object, property } => { + // `$o?->{$expr}` evaluates `$expr` only when the receiver is non-null: conditional. + self.hiding(); + self.expr(object); + self.expr(property); + } + ExprKind::BufferNew { element_type: _, len } => self.expr(len), + ExprKind::FirstClassCallable(target) => { + if let CallableTarget::Method { object, method: _ } = target { + self.expr(object); + } + } + ExprKind::StringLiteral(_) + | ExprKind::IntLiteral(_) + | ExprKind::FloatLiteral(_) + | ExprKind::BoolLiteral(_) + | ExprKind::Null + | ExprKind::ConstRef(_) + | ExprKind::This + | ExprKind::StaticPropertyAccess { .. } + | ExprKind::ClassConstant { .. } + | ExprKind::ScopedConstantAccess { .. } + | ExprKind::MagicConstant(_) => {} + } + } + + /// Walks a list of expressions. + fn exprs(&mut self, exprs: &[Expr]) { + for expr in exprs { + if self.done() { + return; + } + self.expr(expr); + } + } +} + +/// Returns true when a local can be converted in place at the CURRENT program point. +/// +/// Split out of `local_slot_is_convertible` so the per-statement candidate scan does not clone the +/// definitely-initialized slot set once per statement. +pub(super) fn local_slot_kind_is_convertible( + ctx: &LoweringContext<'_, '_>, + name: &str, +) -> bool { + matches!( + ctx.local_kinds.get(name).copied().unwrap_or(LocalKind::PhpLocal), + LocalKind::PhpLocal | LocalKind::StaticLocal + ) && !ctx.is_ref_bound_local(name) + && !ctx.local_uses_global_storage(name) +} diff --git a/src/ir_lower/stmt/typed_foreach.rs b/src/ir_lower/stmt/typed_foreach.rs index 6ad8cd5de2..dffe6c1288 100644 --- a/src/ir_lower/stmt/typed_foreach.rs +++ b/src/ir_lower/stmt/typed_foreach.rs @@ -70,6 +70,16 @@ pub(super) fn coerce_typed_assign_value( } match target_ty { PhpType::Mixed => ctx.box_value_as_mixed(value, PhpType::Mixed, Some(span)), + target @ (PhpType::Callable | PhpType::Object(_)) if source_ty == PhpType::Mixed => { + ctx.emit_value( + Op::MixedUnbox, + vec![value.value], + None, + target, + Op::MixedUnbox.default_effects(), + Some(span), + ) + } _ => value, } } diff --git a/src/ir_lower/tests/corpus.rs b/src/ir_lower/tests/corpus.rs index d096d04348..eb7159dec6 100644 --- a/src/ir_lower/tests/corpus.rs +++ b/src/ir_lower/tests/corpus.rs @@ -42,15 +42,12 @@ fn lowers_examples_corpus() { } /// Returns all example `main.php` and `main.lfc` fixtures in deterministic order, excluding -/// examples that only type-check once a feature prelude has been injected. +/// examples that require a feature prelude or optional-driver build profile. /// /// The corpus lowers each fixture in plain (CLI) mode, which does not inject the -/// feature preludes the pipeline adds during a real compile — the `--web` request -/// prelude (`src/web_prelude.rs`) or the pay-for-use OPcache prelude -/// (`src/opcache_prelude.rs`). Examples that rely on such a prelude — session -/// functions, request superglobals, or the prelude-provided `opcache_*` functions — -/// reference symbols that do not exist in plain CLI-mode lowering and legitimately -/// fail type checking here, so they are skipped rather than treated as failures. +/// feature preludes and optional PDO driver surfaces that the pipeline adds during a +/// real compile. Those profiles have dedicated tests, so their examples are skipped +/// rather than treated as failures in default-profile lowering. fn example_main_files(root: &Path) -> Vec { let examples = root.join("examples"); std::fs::read_dir(&examples) @@ -62,15 +59,21 @@ fn example_main_files(root: &Path) -> Vec { .map(|name| directory.join(name)) .find(|path| path.exists()) }) - .filter(|path| !example_requires_prelude(path)) + .filter(|path| !example_requires_non_default_profile(path)) .collect() } -/// Returns true when an example directory only compiles once the pipeline injects a -/// feature prelude (the `--web` request prelude or the OPcache prelude) and must be -/// skipped by the plain CLI-mode corpus lowering test. -fn example_requires_prelude(main_php: &Path) -> bool { - const PRELUDE_ONLY_EXAMPLES: &[&str] = &[ +/// Returns true when an example needs a feature prelude or optional PDO driver profile. +fn example_requires_non_default_profile(main_php: &Path) -> bool { + const NON_DEFAULT_PROFILE_EXAMPLES: &[&str] = &[ + "pdo-cubrid", + "pdo-dblib", + "pdo-firebird", + "pdo-ibm", + "pdo-informix", + "pdo-oci", + "pdo-odbc", + "pdo-sqlsrv", "web-session", "web-session-trans-sid", "web-session-upload", @@ -82,5 +85,5 @@ fn example_requires_prelude(main_php: &Path) -> bool { .parent() .and_then(|dir| dir.file_name()) .and_then(|name| name.to_str()) - .is_some_and(|name| PRELUDE_ONLY_EXAMPLES.contains(&name)) + .is_some_and(|name| NON_DEFAULT_PROFILE_EXAMPLES.contains(&name)) } diff --git a/src/ir_lower/tests/exhaustive.rs b/src/ir_lower/tests/exhaustive.rs index ed2f53c7d3..fc21a6247d 100644 --- a/src/ir_lower/tests/exhaustive.rs +++ b/src/ir_lower/tests/exhaustive.rs @@ -171,6 +171,7 @@ fn class_info(_class_name: &str) -> ClassInfo { is_readonly_class: false, allow_dynamic_properties: true, constants: HashMap::new(), + constant_deprecations: HashMap::new(), constant_types: HashMap::new(), constant_visibilities: Default::default(), final_constants: Default::default(), diff --git a/src/ir_passes/inline.rs b/src/ir_passes/inline.rs index 726164b986..47009fdef2 100644 --- a/src/ir_passes/inline.rs +++ b/src/ir_passes/inline.rs @@ -263,6 +263,17 @@ fn is_eligible_callee(callee: &Function, recursive: &HashSet) -> bool { if callee.flags.is_generator || callee.flags.is_fiber_wrapper { return false; } + // A by-value container parameter is privatized on entry into an owning shadow slot + // (`ir_lower::context::privatize_container_param`), which is what gives PHP its by-value array + // semantics. That shadow's `StoreLocal` was lowered at FUNCTION ENTRY, where the loop stack is + // empty, so it carries no release-of-previous. Splice the body into a host LOOP and the shadow + // is overwritten on every iteration without releasing what it held — an N-1 leak. Refuse to + // inline such a callee until the inliner reproduces the ownership ABI itself (it would have to + // emit a `Release` of each transplanted shadow on the continuation edge). Keeping `-O` and + // `-O0` semantically identical is worth more than inlining these. + if callee_has_by_value_container_param(callee) { + return false; + } if has_exception_handlers(callee) { return false; } @@ -626,6 +637,9 @@ fn transplant_callee_body( local.php_type.clone(), kind, ); + if excluded_from_cleanup.contains(&local.id) { + host.no_epilogue_cleanup_slots.insert(new_id); + } local_map.insert(local.id, new_id); } @@ -1041,3 +1055,17 @@ pub(crate) fn inline_small_functions(module: &mut Module) -> bool { mod tests { // Real tests are in src/ir_passes/tests/inline_test.rs (Builder-driven, per repo policy). } + +/// Returns whether a callee takes a by-value array or associative-array parameter. +/// +/// Such a parameter is privatized into an owning shadow slot at function entry, and that shadow +/// cannot currently be transplanted safely into a host loop; see the gate in `is_eligible_callee`. +fn callee_has_by_value_container_param(callee: &Function) -> bool { + callee.params.iter().any(|param| { + !param.by_ref + && matches!( + param.php_type.codegen_repr(), + crate::types::PhpType::Array(_) | crate::types::PhpType::AssocArray { .. } + ) + }) +} diff --git a/src/lib.rs b/src/lib.rs index 5f2cdd1b9e..be385745e3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,6 +61,8 @@ pub mod opcache_prelude; pub mod optimize; /// Parser for PHP syntax. pub mod parser; +/// Selected PHP compatibility version for version-sensitive compiler surfaces. +pub mod php_version; /// PDO (SQLite) standard-library prelude injection. pub mod pdo_prelude; diff --git a/src/link_planning.rs b/src/link_planning.rs index 1cfd30e367..42b2664415 100644 --- a/src/link_planning.rs +++ b/src/link_planning.rs @@ -81,6 +81,11 @@ pub(crate) fn build(inputs: LinkPlanningInputs<'_>) -> LinkPlan { }; push_named_once(&mut plan, &mut named, library, origin); } + if named.contains("elephc_pdo") { + for library in linker::pdo_system_libraries() { + push_named_once(&mut plan, &mut named, library, LinkOrigin::Runtime); + } + } for requirement in inputs.runtime_requirements { match requirement { LinkRequirement::NativePackage(_) => {} diff --git a/src/linker/bridges.rs b/src/linker/bridges.rs index 86cc51c0f1..3fa9d90922 100644 --- a/src/linker/bridges.rs +++ b/src/linker/bridges.rs @@ -342,6 +342,17 @@ impl BridgeStaticlib { } } if let Some(archive) = self.find_archive() { + if self.lib_name == "elephc_pdo" + && super::pdo::profile_selected() + && self.claim_rebuild_attempt() + { + if let Some(workspace) = self.find_workspace() { + self.build_staticlib(&workspace); + if let Some(rebuilt) = self.find_archive() { + return self.validate_archive(rebuilt); + } + } + } return self.validate_archive(self.refreshed_if_stale(archive)); } if let Some(workspace) = self.find_workspace() { @@ -487,6 +498,12 @@ impl BridgeStaticlib { .is_some_and(|directory| directory.file_name().is_some_and(|name| name == "release")); let mut command = Command::new("cargo"); command.args(["build", "-p", self.crate_name]); + if self.lib_name == "elephc_pdo" { + let features = super::pdo::cargo_features(); + if !features.is_empty() { + command.args(["--features", &features.join(",")]); + } + } if release { command.arg("--release"); } diff --git a/src/linker/command.rs b/src/linker/command.rs index b8ce9ee980..c99e0e5df7 100644 --- a/src/linker/command.rs +++ b/src/linker/command.rs @@ -139,7 +139,6 @@ fn render_macos_command( paths.bin.as_os_str().to_owned(), paths.object.as_os_str().to_owned(), paths.runtime.as_os_str().to_owned(), - OsString::from("-lSystem"), OsString::from("-syslibroot"), OsString::from(sdk.path), OsString::from("-platform_version"), @@ -155,6 +154,9 @@ fn render_macos_command( } } append_link_inputs(&mut args, plan, Platform::MacOS); + // FreeTDS also exports `dbopen`; keeping native dependencies before libSystem + // prevents ld64 from binding PDO_DBLIB to Berkeley DB's incompatible symbol. + args.push(OsString::from("-lSystem")); append_frameworks(&mut args, plan); RenderedCommand { diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 3508b3fdce..57d9870f6f 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -13,6 +13,7 @@ mod archive_dedup; mod bridges; mod command; +mod pdo; mod sdk; use std::path::{Path, PathBuf}; @@ -69,6 +70,11 @@ pub(crate) fn php_extension_for_lib(lib_name: &str) -> Option<&'static str> { bridges::php_extension_for_lib(lib_name) } +/// Returns native libraries required by the selected optional PDO bridge profile. +pub(crate) fn pdo_system_libraries() -> Vec<&'static str> { + pdo::system_libraries() +} + /// Invokes the target assembler for one generated assembly source file. pub(crate) fn assemble(target: Target, asm_path: &Path, obj_path: &Path) { let mut assembler = Command::new(target.assembler_cmd()); diff --git a/src/linker/pdo.rs b/src/linker/pdo.rs new file mode 100644 index 0000000000..2a1826168e --- /dev/null +++ b/src/linker/pdo.rs @@ -0,0 +1,100 @@ +//! Purpose: +//! Centralizes optional PDO bridge profile selection for archive builds and final links. +//! +//! Called from: +//! - `crate::linker::bridges` when materializing `libelephc_pdo.a`. +//! - `crate::link_planning` when adding native driver-manager dependencies. +//! +//! Key details: +//! - Cargo features and their CI environment equivalents select the same bridge profile. +//! - Only libpq, FreeTDS, and ODBC-family profiles add direct final-link libraries. + +/// Returns whether an optional profile is selected by a Cargo feature or CI environment flag. +fn selected(feature_enabled: bool, environment: &str) -> bool { + feature_enabled || std::env::var_os(environment).is_some() +} + +/// Returns Cargo feature names required when rebuilding the PDO bridge in this process. +pub(super) fn cargo_features() -> Vec<&'static str> { + let mut features = Vec::new(); + for (enabled, feature) in [ + ( + selected(cfg!(feature = "pdo-libpq-gss"), "ELEPHC_PDO_LIBPQ"), + "libpq-gss", + ), + ( + selected(cfg!(feature = "pdo-dblib"), "ELEPHC_PDO_DBLIB"), + "dblib", + ), + ( + selected(cfg!(feature = "pdo-firebird"), "ELEPHC_PDO_FIREBIRD"), + "firebird", + ), + ( + selected(cfg!(feature = "pdo-odbc"), "ELEPHC_PDO_ODBC"), + "odbc", + ), + ( + selected(cfg!(feature = "pdo-informix"), "ELEPHC_PDO_INFORMIX"), + "informix", + ), + ( + selected(cfg!(feature = "pdo-ibm"), "ELEPHC_PDO_IBM"), + "ibm", + ), + ( + selected(cfg!(feature = "pdo-sqlsrv"), "ELEPHC_PDO_SQLSRV"), + "sqlsrv", + ), + ( + selected(cfg!(feature = "pdo-oci"), "ELEPHC_PDO_OCI"), + "oci", + ), + ( + selected(cfg!(feature = "pdo-cubrid"), "ELEPHC_PDO_CUBRID"), + "cubrid", + ), + ] { + if enabled { + features.push(feature); + } + } + features +} + +/// Returns whether the default PDO archive must be replaced by an optional profile build. +pub(super) fn profile_selected() -> bool { + !cargo_features().is_empty() +} + +/// Returns native libraries required by the selected PDO archive profile. +pub(super) fn system_libraries() -> Vec<&'static str> { + let mut libraries = Vec::new(); + if selected(cfg!(feature = "pdo-libpq-gss"), "ELEPHC_PDO_LIBPQ") { + libraries.push("pq"); + } + if selected(cfg!(feature = "pdo-dblib"), "ELEPHC_PDO_DBLIB") { + libraries.push("sybdb"); + } + if selected(cfg!(feature = "pdo-odbc"), "ELEPHC_PDO_ODBC") + || selected(cfg!(feature = "pdo-informix"), "ELEPHC_PDO_INFORMIX") + || selected(cfg!(feature = "pdo-ibm"), "ELEPHC_PDO_IBM") + || selected(cfg!(feature = "pdo-sqlsrv"), "ELEPHC_PDO_SQLSRV") + { + libraries.push("odbc"); + } + libraries +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verifies the selected feature list never repeats a Cargo feature. + #[test] + fn selected_cargo_features_are_unique() { + let features = cargo_features(); + let unique = features.iter().copied().collect::>(); + assert_eq!(features.len(), unique.len()); + } +} diff --git a/src/linker/sdk.rs b/src/linker/sdk.rs index 642c5245e7..986d520083 100644 --- a/src/linker/sdk.rs +++ b/src/linker/sdk.rs @@ -43,7 +43,16 @@ fn validate_macos_sdk_path(resolved: &str) -> Result { /// Returns common existing Homebrew library directories in stable preference order. pub(super) fn default_macos_library_paths() -> Vec<&'static str> { - ["/opt/homebrew/lib", "/usr/local/lib"] + [ + "/opt/homebrew/lib", + "/usr/local/lib", + "/opt/homebrew/opt/libpq/lib", + "/usr/local/opt/libpq/lib", + "/opt/homebrew/opt/freetds/lib", + "/usr/local/opt/freetds/lib", + "/opt/homebrew/opt/unixodbc/lib", + "/usr/local/opt/unixodbc/lib", + ] .into_iter() .filter(|path| Path::new(path).exists()) .collect() diff --git a/src/main.rs b/src/main.rs index 83b07ec25b..7bf204ca5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,7 @@ mod opcache; mod opcache_prelude; mod optimize; mod parser; +mod php_version; mod pdo_prelude; mod php_profile; mod pipeline; diff --git a/src/optimize/control/dce.rs b/src/optimize/control/dce.rs index 1976b489ec..9d6079a409 100644 --- a/src/optimize/control/dce.rs +++ b/src/optimize/control/dce.rs @@ -38,16 +38,15 @@ pub(crate) fn dce_block(body: Vec) -> Vec { /// tracks guard state, handles tail-sinking for if/switch/try, and breaks early on terminal control flow. fn dce_block_with_guards(body: Vec, mut guards: GuardState) -> Vec { let mut eliminated = Vec::new(); - let mut stmts = body.into_iter().peekable(); + let mut stmts = body.into_iter(); while let Some(stmt) = stmts.next() { - let has_tail = stmts.peek().is_some(); + let has_tail = !stmts.as_slice().is_empty(); let mut use_tail_sink = has_tail && matches!( stmt.kind, StmtKind::If { .. } | StmtKind::IfDef { .. } | StmtKind::Switch { .. } | StmtKind::Try { .. } ); let dce_stmt = if use_tail_sink { - let tail: Vec = stmts.clone().collect(); // Tail-sinking copies the tail into each branch of the if/switch/try. // Declarations (functions, classes, interfaces, enums, traits, externs) // are hoisted and must stay singular — sinking one into multiple @@ -56,10 +55,13 @@ fn dce_block_with_guards(body: Vec, mut guards: GuardState) -> Vec { // sinking them duplicates nested control flow and produces exponential // AST growth (each successive if duplicates the remaining tail). // Fall back to plain per-statement DCE when the tail contains either. - if stmts_contain_declaration(&tail) || stmts_contain_control_flow(&tail) { + if stmts_contain_declaration(stmts.as_slice()) + || stmts_contain_control_flow(stmts.as_slice()) + { use_tail_sink = false; dce_stmt_with_guards(stmt, &guards) } else { + let tail = stmts.by_ref().collect(); dce_stmt_with_tail(stmt, tail, &guards) } } else { @@ -134,6 +136,26 @@ mod tests { assert!(matches!(statements[0].kind, StmtKind::Synthetic(_))); assert!(stmt_contains_control_flow(&statements[0])); } + + /// Keeps large control-flow-heavy bodies linear by inspecting the remaining tail by reference. + #[test] + fn large_control_flow_tail_is_processed_without_ast_cloning() { + let mut source = String::from(" {value}) {{ echo {value}; }}")); + } + source.push_str("echo 'done'; }"); + let tokens = crate::lexer::tokenize(&source).expect("tokenize large DCE fixture"); + let mut statements = crate::parser::parse(&tokens).expect("parse large DCE fixture"); + let StmtKind::FunctionDecl { body, .. } = statements.remove(0).kind else { + panic!("expected function declaration fixture"); + }; + + let eliminated = dce_block(body); + // The final echo is legally tail-sunk into the last if, while the preceding 511 ifs stay + // singular because their remaining tail still contains control flow. + assert_eq!(eliminated.len(), 512); + } } /// Returns true when `stmt` is, or recursively contains, a symbol-emitting diff --git a/src/optimize/control/dce/methods.rs b/src/optimize/control/dce/methods.rs index 3e31c04e15..cf6b0b1834 100644 --- a/src/optimize/control/dce/methods.rs +++ b/src/optimize/control/dce/methods.rs @@ -7,14 +7,25 @@ //! //! Key details: //! - The pass must remain conservative around throws, finally blocks, switch fallthrough, method calls, and variable writes. +//! - The generated PDO constructor and attribute dispatchers bypass DCE because their large +//! branch chains are trusted compiler input and provide no useful optimization opportunity. use super::*; /// Applies DCE to a class method, recording the class context for effect tracking. /// `class_name` is used for effect correlation; `parent_name` tracks inheritance /// when present. Preserves observable effects (throws, calls, writes) while -/// removing unreachable tails and dead branches within the method body. +/// removing unreachable tails and dead branches within the method body. The +/// compiler-owned PDO constructor and attribute dispatchers are retained verbatim because their +/// large branch chains make tail-sensitive DCE expensive while offering no semantic benefit. pub(crate) fn dce_method(method: ClassMethod, class_name: &str, parent_name: Option<&str>) -> ClassMethod { + if class_name.eq_ignore_ascii_case("PDO") + && (method.name.eq_ignore_ascii_case("__construct") + || method.name.eq_ignore_ascii_case("setAttribute") + || method.name.eq_ignore_ascii_case("getAttribute")) + { + return method; + } let context = ClassEffectContext { class_name: class_name.to_string(), parent_name: parent_name.map(str::to_string), diff --git a/src/parser/ast/mod.rs b/src/parser/ast/mod.rs index b767c41c7e..9831b671ee 100644 --- a/src/parser/ast/mod.rs +++ b/src/parser/ast/mod.rs @@ -27,3 +27,12 @@ pub use oop::{ }; pub use stmt::{CatchClause, Program, Stmt, StmtKind, UseItem, UseKind}; pub use types::TypeExpr; + +/// Name prefix of the temporary a nested append (`$a[$k][] = $v`) reads its bucket into. +/// +/// The parser mints it in `stmt::assign::postfix::lower_nested_append_assignment`; IR lowering +/// matches on it to recognize a nested-append `StmtKind::Synthetic` group and fuse it (see +/// `crate::ir_lower::stmt::nested_append`). It lives here, on the AST, because it is the shared +/// contract between those two — and it must not be reused by any other desugar, or that +/// recognizer would claim statements it does not own. +pub const NESTED_APPEND_TEMP_PREFIX: &str = "__elephc_napp_"; diff --git a/src/parser/stmt/assign/postfix.rs b/src/parser/stmt/assign/postfix.rs index de6cc51979..ef81a60bc9 100644 --- a/src/parser/stmt/assign/postfix.rs +++ b/src/parser/stmt/assign/postfix.rs @@ -10,7 +10,9 @@ use crate::errors::CompileError; use crate::lexer::{SpannedToken, Token}; -use crate::parser::ast::{BinOp, Expr, ExprKind, InstanceOfTarget, Stmt, StmtKind}; +use crate::parser::ast::{ + BinOp, Expr, ExprKind, InstanceOfTarget, Stmt, StmtKind, NESTED_APPEND_TEMP_PREFIX, +}; use crate::parser::expr::{parse_assignment_value_expr, parse_expr}; use crate::span::Span; @@ -145,7 +147,7 @@ fn lower_nested_append_assignment( ) -> Result { let mut lowerer = EffectfulTargetLowerer::new(span); let target = lowerer.stabilize_array_target(target); - let temp = lowerer.next_temp_name(); + let temp = lowerer.next_nested_append_temp_name(); lowerer.stmts.push(Stmt::new( StmtKind::Assign { name: temp.clone(), @@ -332,6 +334,19 @@ pub(in crate::parser::stmt) fn try_parse_scoped_property_assignment( } let value = assignment_value(lhs_expr.clone(), op, rhs, span); + // `self::$b[$k][] = $v` (and its `static::` / `parent::` / `Named::` siblings) is an append + // through a *nested* target. The trailing `[]` was stripped from the LHS tokens above, so + // `lhs_expr` is an `ExprKind::ArrayAccess` and the `if is_append` guard on the bare + // `StaticPropertyAccess` arm below — which only ever handles `self::$b[] = $v` — cannot + // match it. Left alone it falls into the plain `ArrayAccess` arm, which ignores `is_append` + // entirely and emits `StaticPropertyArrayAssign`: the append is silently dropped and the + // bucket is OVERWRITTEN with the single value. Route it through the same read/append/ + // write-back desugar every other nested-append target already uses; the write-back builder + // (`assignment_target_store_stmt`) already supports the static-property family. + if is_append && matches!(lhs_expr.kind, ExprKind::ArrayAccess { .. }) { + return lower_nested_append_assignment(lhs_expr, value, span).map(Some); + } + let stmt = match lhs_expr.kind { ExprKind::StaticPropertyAccess { receiver, property } if is_append => { StmtKind::StaticPropertyArrayPush { @@ -744,6 +759,24 @@ impl EffectfulTargetLowerer { name } + /// Mints the temporary that holds the bucket of a nested append, under its own reserved + /// prefix. + /// + /// The prefix is what lets IR lowering recognize a nested-append `Synthetic` group and + /// lower it as a fused, in-place append instead of the read/copy/write-back it desugars + /// to here. `next_temp_name`'s prefix is shared with the `.=` / `+=` desugars, which emit + /// the same statement shapes, so it cannot serve as that signal. PHP source cannot forge + /// either lock: the name is not a legal PHP identifier and `StmtKind::Synthetic` has no + /// surface syntax. + fn next_nested_append_temp_name(&mut self) -> String { + let name = format!( + "{}{}_{}_{}", + NESTED_APPEND_TEMP_PREFIX, self.span.line, self.span.col, self.next_temp + ); + self.next_temp += 1; + name + } + /// Stabilizes an array-access target, recursively stabilizing both the array base /// and the index. For simple array bases (Variable, This, StaticPropertyAccess), /// the array base is kept as-is; deeper bases are stabilized via `stabilize_array_base`. diff --git a/src/pdo_prelude.rs b/src/pdo_prelude.rs index a03a793999..fc8d5097ff 100644 --- a/src/pdo_prelude.rs +++ b/src/pdo_prelude.rs @@ -24,10 +24,19 @@ //! plain method-local `$stmt`. The `$_` prefix also exempts them from the //! unused-variable warning. +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + use crate::parser::ast::Program; +use crate::php_version::PhpVersion; mod detect; +type PreludeCacheKey = (PhpVersion, u8); + +static PARSED_PRELUDE_CACHE: OnceLock>> = OnceLock::new(); + /// The elephc-PHP source implementing PDO over the driver-agnostic `elephc_pdo` /// bridge (SQLite + PostgreSQL + MySQL/MariaDB). /// @@ -39,10 +48,47 @@ mod detect; pub const PDO_PRELUDE_SRC: &str = r#" 4, + // timestamp -> 8, uuid -> 16), and -1 for a VARLENA (text/varchar/numeric/bytea/ + // json/arrays). A VARCHAR(20) therefore reports len -1, NOT 20 — its declared 20 + // surfaces through precision instead. That is real PDO, not an approximation. + // - precision = PQfmod(): the RAW atttypmod, undecoded, exactly as php-src stores + // it — VARCHAR(20) is 24 (20 + VARHDRSZ), NUMERIC(10,2) is 655366 + // (((10 << 16) | 2) + 4). Decoding it here would be a divergence dressed up as an + // improvement. + // v26 ALSO widens elephc_pdo_column_native_type (declared with the v23 pair above) + // to mysql: statements, which now report MySQL's own wire-type names ("LONG", + // "VAR_STRING", "NEWDECIMAL", "BLOB", …) per php-src's type_to_name_native. + function elephc_pdo_column_table_oid(int $stmt, int $i): int; + function elephc_pdo_column_len(int $stmt, int $i): int; + function elephc_pdo_column_precision(int $stmt, int $i): int; + // v49: the remaining PDO_DBLIB `getColumnMeta()` descriptor fields. + function elephc_pdo_dblib_column_native_type_id(int $stmt, int $i): int; + function elephc_pdo_dblib_column_user_type_id(int $stmt, int $i): int; + function elephc_pdo_dblib_column_scale(int $stmt, int $i): int; + function elephc_pdo_dblib_column_source(int $stmt, int $i): string; } -class PDOException extends RuntimeException { +// F-SURF-01: php-src's ext/pdo/pdo.stub.php declares a GLOBAL `pdo_drivers(): array` +// alongside the class surface — the procedural spelling of PDO::getAvailableDrivers(), +// and still the one most capability probes reach for +// (`in_array('pgsql', pdo_drivers(), true)`). It was absent here entirely, so such a +// probe failed to compile rather than reporting the drivers this build has. +// +function pdo_drivers(): array { + $_drivers = []; + $_count = elephc_pdo_available_driver_count(); + for ($_index = 0; $_index < $_count; $_index++) { + $_drivers[] = elephc_pdo_available_driver_name($_index); + } + return $_drivers; } -class PDO { - const FETCH_ASSOC = 2; - const FETCH_NUM = 3; - const FETCH_BOTH = 4; - const FETCH_OBJ = 5; - const FETCH_COLUMN = 7; - const FETCH_CLASS = 8; - const FETCH_INTO = 9; - const PARAM_NULL = 0; - const PARAM_INT = 1; - const PARAM_STR = 2; - const PARAM_BOOL = 5; - const ATTR_ERRMODE = 3; - const ATTR_PERSISTENT = 12; - const ATTR_DRIVER_NAME = 16; - const ERRMODE_SILENT = 0; - const ERRMODE_WARNING = 1; - const ERRMODE_EXCEPTION = 2; +// Maps a SQLSTATE to the human-readable class description PDO interpolates into a +// driver-error message — e.g. "General error" for HY000, so a failed sqlite query +// reads "SQLSTATE[HY000]: General error: 1 no such table: t" exactly like php-src. +// Mirrors the complete PHP 8.4 `pdo_sqlstate_state_to_description` table +// (ext/pdo/pdo_sqlstate.c); an unknown state degrades to php-src's own +// "<>" fallback. +function __elephc_pdo_sqlstate_description_0(string $state): string { + if ($state === "00000") { return "No error"; } + if ($state === "01000") { return "Warning"; } + if ($state === "01001") { return "Cursor operation conflict"; } + if ($state === "01002") { return "Disconnect error"; } + if ($state === "01003") { return "NULL value eliminated in set function"; } + if ($state === "01004") { return "String data, right truncated"; } + if ($state === "01006") { return "Privilege not revoked"; } + if ($state === "01007") { return "Privilege not granted"; } + if ($state === "01008") { return "Implicit zero bit padding"; } + if ($state === "0100C") { return "Dynamic result sets returned"; } + if ($state === "01P01") { return "Deprecated feature"; } + if ($state === "01S00") { return "Invalid connection string attribute"; } + if ($state === "01S01") { return "Error in row"; } + if ($state === "01S02") { return "Option value changed"; } + if ($state === "01S06") { return "Attempt to fetch before the result set returned the first rowset"; } + if ($state === "01S07") { return "Fractional truncation"; } + if ($state === "01S08") { return "Error saving File DSN"; } + if ($state === "01S09") { return "Invalid keyword"; } + if ($state === "02000") { return "No data"; } + if ($state === "02001") { return "No additional dynamic result sets returned"; } + if ($state === "03000") { return "Sql statement not yet complete"; } + if ($state === "07002") { return "COUNT field incorrect"; } + if ($state === "07005") { return "Prepared statement not a cursor-specification"; } + if ($state === "07006") { return "Restricted data type attribute violation"; } + if ($state === "07009") { return "Invalid descriptor index"; } + if ($state === "07S01") { return "Invalid use of default parameter"; } + if ($state === "08000") { return "Connection exception"; } + if ($state === "08001") { return "Client unable to establish connection"; } + if ($state === "08002") { return "Connection name in use"; } + if ($state === "08003") { return "Connection does not exist"; } + if ($state === "08004") { return "Server rejected the connection"; } + if ($state === "08006") { return "Connection failure"; } + if ($state === "08007") { return "Connection failure during transaction"; } + if ($state === "08S01") { return "Communication link failure"; } + if ($state === "09000") { return "Triggered action exception"; } + if ($state === "0A000") { return "Feature not supported"; } + if ($state === "0B000") { return "Invalid transaction initiation"; } + if ($state === "0F000") { return "Locator exception"; } + if ($state === "0F001") { return "Invalid locator specification"; } + if ($state === "0L000") { return "Invalid grantor"; } + if ($state === "0LP01") { return "Invalid grant operation"; } + if ($state === "0P000") { return "Invalid role specification"; } + return ""; +} - private int $conn; - private int $errMode; - private bool $persistent; - private array $attributes; +function __elephc_pdo_sqlstate_description_2(string $state): string { + if ($state === "21000") { return "Cardinality violation"; } + if ($state === "21S01") { return "Insert value list does not match column list"; } + if ($state === "21S02") { return "Degree of derived table does not match column list"; } + if ($state === "22000") { return "Data exception"; } + if ($state === "22001") { return "String data, right truncated"; } + if ($state === "22002") { return "Indicator variable required but not supplied"; } + if ($state === "22003") { return "Numeric value out of range"; } + if ($state === "22004") { return "Null value not allowed"; } + if ($state === "22005") { return "Error in assignment"; } + if ($state === "22007") { return "Invalid datetime format"; } + if ($state === "22008") { return "Datetime field overflow"; } + if ($state === "22009") { return "Invalid time zone displacement value"; } + if ($state === "2200B") { return "Escape character conflict"; } + if ($state === "2200C") { return "Invalid use of escape character"; } + if ($state === "2200D") { return "Invalid escape octet"; } + if ($state === "2200F") { return "Zero length character string"; } + if ($state === "2200G") { return "Most specific type mismatch"; } + if ($state === "22010") { return "Invalid indicator parameter value"; } + if ($state === "22011") { return "Substring error"; } + if ($state === "22012") { return "Division by zero"; } + if ($state === "22015") { return "Interval field overflow"; } + if ($state === "22018") { return "Invalid character value for cast specification"; } + if ($state === "22019") { return "Invalid escape character"; } + if ($state === "2201B") { return "Invalid regular expression"; } + if ($state === "2201E") { return "Invalid argument for logarithm"; } + if ($state === "2201F") { return "Invalid argument for power function"; } + if ($state === "2201G") { return "Invalid argument for width bucket function"; } + if ($state === "22020") { return "Invalid limit value"; } + if ($state === "22021") { return "Character not in repertoire"; } + if ($state === "22022") { return "Indicator overflow"; } + if ($state === "22023") { return "Invalid parameter value"; } + if ($state === "22024") { return "Unterminated c string"; } + if ($state === "22025") { return "Invalid escape sequence"; } + if ($state === "22026") { return "String data, length mismatch"; } + if ($state === "22027") { return "Trim error"; } + if ($state === "2202E") { return "Array subscript error"; } + if ($state === "22P01") { return "Floating point exception"; } + if ($state === "22P02") { return "Invalid text representation"; } + if ($state === "22P03") { return "Invalid binary representation"; } + if ($state === "22P04") { return "Bad copy file format"; } + if ($state === "22P05") { return "Untranslatable character"; } + if ($state === "23000") { return "Integrity constraint violation"; } + if ($state === "23001") { return "Restrict violation"; } + if ($state === "23502") { return "Not null violation"; } + if ($state === "23503") { return "Foreign key violation"; } + if ($state === "23505") { return "Unique violation"; } + if ($state === "23514") { return "Check violation"; } + if ($state === "24000") { return "Invalid cursor state"; } + if ($state === "25000") { return "Invalid transaction state"; } + if ($state === "25001") { return "Active sql transaction"; } + if ($state === "25002") { return "Branch transaction already active"; } + if ($state === "25003") { return "Inappropriate access mode for branch transaction"; } + if ($state === "25004") { return "Inappropriate isolation level for branch transaction"; } + if ($state === "25005") { return "No active sql transaction for branch transaction"; } + if ($state === "25006") { return "Read only sql transaction"; } + if ($state === "25007") { return "Schema and data statement mixing not supported"; } + if ($state === "25008") { return "Held cursor requires same isolation level"; } + if ($state === "25P01") { return "No active sql transaction"; } + if ($state === "25P02") { return "In failed sql transaction"; } + if ($state === "25S01") { return "Transaction state"; } + if ($state === "25S02") { return "Transaction is still active"; } + if ($state === "25S03") { return "Transaction is rolled back"; } + if ($state === "26000") { return "Invalid sql statement name"; } + if ($state === "27000") { return "Triggered data change violation"; } + if ($state === "28000") { return "Invalid authorization specification"; } + if ($state === "2B000") { return "Dependent privilege descriptors still exist"; } + if ($state === "2BP01") { return "Dependent objects still exist"; } + if ($state === "2D000") { return "Invalid transaction termination"; } + if ($state === "2F000") { return "Sql routine exception"; } + if ($state === "2F002") { return "Modifying sql data not permitted"; } + if ($state === "2F003") { return "Prohibited sql statement attempted"; } + if ($state === "2F004") { return "Reading sql data not permitted"; } + if ($state === "2F005") { return "Function executed no return statement"; } + return ""; +} - public function __construct(string $dsn, ?string $username = null, ?string $password = null, ?array $options = null) { - $this->errMode = 2; - $this->persistent = false; - $this->attributes = []; - // Constructor options affect the connection that is opened below, so - // apply them before the bridge sees the DSN. In particular, - // ATTR_PERSISTENT selects the bridge's process-local DSN pool. - if ($options !== null) { - foreach ($options as $_attr => $_val) { - $_iattr = (int) $_attr; - if ($_iattr == 3) { - $this->errMode = (int) $_val; - } elseif ($_iattr == 12) { - $this->persistent = (bool) $_val; +function __elephc_pdo_sqlstate_description_3(string $state): string { + if ($state === "34000") { return "Invalid cursor name"; } + if ($state === "38000") { return "External routine exception"; } + if ($state === "38001") { return "Containing sql not permitted"; } + if ($state === "38002") { return "Modifying sql data not permitted"; } + if ($state === "38003") { return "Prohibited sql statement attempted"; } + if ($state === "38004") { return "Reading sql data not permitted"; } + if ($state === "39000") { return "External routine invocation exception"; } + if ($state === "39001") { return "Invalid sqlstate returned"; } + if ($state === "39004") { return "Null value not allowed"; } + if ($state === "39P01") { return "Trigger protocol violated"; } + if ($state === "39P02") { return "Srf protocol violated"; } + if ($state === "3B000") { return "Savepoint exception"; } + if ($state === "3B001") { return "Invalid savepoint specification"; } + if ($state === "3C000") { return "Duplicate cursor name"; } + if ($state === "3D000") { return "Invalid catalog name"; } + if ($state === "3F000") { return "Invalid schema name"; } + return ""; +} + +function __elephc_pdo_sqlstate_description_4(string $state): string { + if ($state === "40000") { return "Transaction rollback"; } + if ($state === "40001") { return "Serialization failure"; } + if ($state === "40002") { return "Transaction integrity constraint violation"; } + if ($state === "40003") { return "Statement completion unknown"; } + if ($state === "40P01") { return "Deadlock detected"; } + if ($state === "42000") { return "Syntax error or access violation"; } + if ($state === "42501") { return "Insufficient privilege"; } + if ($state === "42601") { return "Syntax error"; } + if ($state === "42602") { return "Invalid name"; } + if ($state === "42611") { return "Invalid column definition"; } + if ($state === "42622") { return "Name too long"; } + if ($state === "42701") { return "Duplicate column"; } + if ($state === "42702") { return "Ambiguous column"; } + if ($state === "42703") { return "Undefined column"; } + if ($state === "42704") { return "Undefined object"; } + if ($state === "42710") { return "Duplicate object"; } + if ($state === "42712") { return "Duplicate alias"; } + if ($state === "42723") { return "Duplicate function"; } + if ($state === "42725") { return "Ambiguous function"; } + if ($state === "42803") { return "Grouping error"; } + if ($state === "42804") { return "Datatype mismatch"; } + if ($state === "42809") { return "Wrong object type"; } + if ($state === "42830") { return "Invalid foreign key"; } + if ($state === "42846") { return "Cannot coerce"; } + if ($state === "42883") { return "Undefined function"; } + if ($state === "42939") { return "Reserved name"; } + if ($state === "42P01") { return "Undefined table"; } + if ($state === "42P02") { return "Undefined parameter"; } + if ($state === "42P03") { return "Duplicate cursor"; } + if ($state === "42P04") { return "Duplicate database"; } + if ($state === "42P05") { return "Duplicate prepared statement"; } + if ($state === "42P06") { return "Duplicate schema"; } + if ($state === "42P07") { return "Duplicate table"; } + if ($state === "42P08") { return "Ambiguous parameter"; } + if ($state === "42P09") { return "Ambiguous alias"; } + if ($state === "42P10") { return "Invalid column reference"; } + if ($state === "42P11") { return "Invalid cursor definition"; } + if ($state === "42P12") { return "Invalid database definition"; } + if ($state === "42P13") { return "Invalid function definition"; } + if ($state === "42P14") { return "Invalid prepared statement definition"; } + if ($state === "42P15") { return "Invalid schema definition"; } + if ($state === "42P16") { return "Invalid table definition"; } + if ($state === "42P17") { return "Invalid object definition"; } + if ($state === "42P18") { return "Indeterminate datatype"; } + if ($state === "42S01") { return "Base table or view already exists"; } + if ($state === "42S02") { return "Base table or view not found"; } + if ($state === "42S11") { return "Index already exists"; } + if ($state === "42S12") { return "Index not found"; } + if ($state === "42S21") { return "Column already exists"; } + if ($state === "42S22") { return "Column not found"; } + if ($state === "44000") { return "WITH CHECK OPTION violation"; } + return ""; +} + +function __elephc_pdo_sqlstate_description_5(string $state): string { + if ($state === "53000") { return "Insufficient resources"; } + if ($state === "53100") { return "Disk full"; } + if ($state === "53200") { return "Out of memory"; } + if ($state === "53300") { return "Too many connections"; } + if ($state === "54000") { return "Program limit exceeded"; } + if ($state === "54001") { return "Statement too complex"; } + if ($state === "54011") { return "Too many columns"; } + if ($state === "54023") { return "Too many arguments"; } + if ($state === "55000") { return "Object not in prerequisite state"; } + if ($state === "55006") { return "Object in use"; } + if ($state === "55P02") { return "Cant change runtime param"; } + if ($state === "55P03") { return "Lock not available"; } + if ($state === "57000") { return "Operator intervention"; } + if ($state === "57014") { return "Query canceled"; } + if ($state === "57P01") { return "Admin shutdown"; } + if ($state === "57P02") { return "Crash shutdown"; } + if ($state === "57P03") { return "Cannot connect now"; } + if ($state === "58030") { return "Io error"; } + if ($state === "58P01") { return "Undefined file"; } + if ($state === "58P02") { return "Duplicate file"; } + return ""; +} + +function __elephc_pdo_sqlstate_description_f(string $state): string { + if ($state === "F0000") { return "Config file error"; } + if ($state === "F0001") { return "Lock file exists"; } + return ""; +} + +function __elephc_pdo_sqlstate_description_h(string $state): string { + if ($state === "HY000") { return "General error"; } + if ($state === "HY001") { return "Memory allocation error"; } + if ($state === "HY003") { return "Invalid application buffer type"; } + if ($state === "HY004") { return "Invalid SQL data type"; } + if ($state === "HY007") { return "Associated statement is not prepared"; } + if ($state === "HY008") { return "Operation canceled"; } + if ($state === "HY009") { return "Invalid use of null pointer"; } + if ($state === "HY010") { return "Function sequence error"; } + if ($state === "HY011") { return "Attribute cannot be set now"; } + if ($state === "HY012") { return "Invalid transaction operation code"; } + if ($state === "HY013") { return "Memory management error"; } + if ($state === "HY014") { return "Limit on the number of handles exceeded"; } + if ($state === "HY015") { return "No cursor name available"; } + if ($state === "HY016") { return "Cannot modify an implementation row descriptor"; } + if ($state === "HY017") { return "Invalid use of an automatically allocated descriptor handle"; } + if ($state === "HY018") { return "Server declined cancel request"; } + if ($state === "HY019") { return "Non-character and non-binary data sent in pieces"; } + if ($state === "HY020") { return "Attempt to concatenate a null value"; } + if ($state === "HY021") { return "Inconsistent descriptor information"; } + if ($state === "HY024") { return "Invalid attribute value"; } + if ($state === "HY090") { return "Invalid string or buffer length"; } + if ($state === "HY091") { return "Invalid descriptor field identifier"; } + if ($state === "HY092") { return "Invalid attribute/option identifier"; } + if ($state === "HY093") { return "Invalid parameter number"; } + if ($state === "HY095") { return "Function type out of range"; } + if ($state === "HY096") { return "Invalid information type"; } + if ($state === "HY097") { return "Column type out of range"; } + if ($state === "HY098") { return "Scope type out of range"; } + if ($state === "HY099") { return "Nullable type out of range"; } + if ($state === "HY100") { return "Uniqueness option type out of range"; } + if ($state === "HY101") { return "Accuracy option type out of range"; } + if ($state === "HY103") { return "Invalid retrieval code"; } + if ($state === "HY104") { return "Invalid precision or scale value"; } + if ($state === "HY105") { return "Invalid parameter type"; } + if ($state === "HY106") { return "Fetch type out of range"; } + if ($state === "HY107") { return "Row value out of range"; } + if ($state === "HY109") { return "Invalid cursor position"; } + if ($state === "HY110") { return "Invalid driver completion"; } + if ($state === "HY111") { return "Invalid bookmark value"; } + if ($state === "HYC00") { return "Optional feature not implemented"; } + if ($state === "HYT00") { return "Timeout expired"; } + if ($state === "HYT01") { return "Connection timeout expired"; } + return ""; +} + +function __elephc_pdo_sqlstate_description_i(string $state): string { + if ($state === "IM001") { return "Driver does not support this function"; } + if ($state === "IM002") { return "Data source name not found and no default driver specified"; } + if ($state === "IM003") { return "Specified driver could not be loaded"; } + if ($state === "IM004") { return "Driver's SQLAllocHandle on SQL_HANDLE_ENV failed"; } + if ($state === "IM005") { return "Driver's SQLAllocHandle on SQL_HANDLE_DBC failed"; } + if ($state === "IM006") { return "Driver's SQLSetConnectAttr failed"; } + if ($state === "IM007") { return "No data source or driver specified; dialog prohibited"; } + if ($state === "IM008") { return "Dialog failed"; } + if ($state === "IM009") { return "Unable to load translation DLL"; } + if ($state === "IM010") { return "Data source name too long"; } + if ($state === "IM011") { return "Driver name too long"; } + if ($state === "IM012") { return "DRIVER keyword syntax error"; } + if ($state === "IM013") { return "Trace file error"; } + if ($state === "IM014") { return "Invalid name of File DSN"; } + if ($state === "IM015") { return "Corrupt file data source"; } + return ""; +} + +function __elephc_pdo_sqlstate_description_p(string $state): string { + if ($state === "P0000") { return "Plpgsql error"; } + if ($state === "P0001") { return "Raise exception"; } + return ""; +} + +function __elephc_pdo_sqlstate_description_x(string $state): string { + if ($state === "XX000") { return "Internal error"; } + if ($state === "XX001") { return "Data corrupted"; } + return ""; +} + +function __elephc_pdo_sqlstate_description(string $state): string { + $_prefix = substr($state, 0, 1); + if ($_prefix === "0") { + $_description = __elephc_pdo_sqlstate_description_0($state); + if ($_description !== "") { return $_description; } + } + if ($_prefix === "2") { + $_description = __elephc_pdo_sqlstate_description_2($state); + if ($_description !== "") { return $_description; } + } + if ($_prefix === "3") { + $_description = __elephc_pdo_sqlstate_description_3($state); + if ($_description !== "") { return $_description; } + } + if ($_prefix === "4") { + $_description = __elephc_pdo_sqlstate_description_4($state); + if ($_description !== "") { return $_description; } + } + if ($_prefix === "5") { + $_description = __elephc_pdo_sqlstate_description_5($state); + if ($_description !== "") { return $_description; } + } + if ($_prefix === "F") { + $_description = __elephc_pdo_sqlstate_description_f($state); + if ($_description !== "") { return $_description; } + } + if ($_prefix === "H") { + $_description = __elephc_pdo_sqlstate_description_h($state); + if ($_description !== "") { return $_description; } + } + if ($_prefix === "I") { + $_description = __elephc_pdo_sqlstate_description_i($state); + if ($_description !== "") { return $_description; } + } + if ($_prefix === "P") { + $_description = __elephc_pdo_sqlstate_description_p($state); + if ($_description !== "") { return $_description; } + } + if ($_prefix === "X") { + $_description = __elephc_pdo_sqlstate_description_x($state); + if ($_description !== "") { return $_description; } + } + return "<>"; +} + +// Formats a synthetic PDO implementation error exactly like php-src's +// `pdo_raise_impl_error`: the standard SQLSTATE description is always present, +// and caller detail is appended once only when non-empty. +function __elephc_pdo_impl_error_message(string $state, string $detail): string { + $_message = "SQLSTATE[" . $state . "]: " . __elephc_pdo_sqlstate_description($state); + if ($detail !== "") { + return $_message . ": " . $detail; + } + return $_message; +} + +class PDOException extends RuntimeException { + // PHP surfaces the [SQLSTATE, driver-specific code, message] triple here; + // frameworks (Doctrine, Laravel) read $e->errorInfo[0] for the SQLSTATE. Typed + // `?array` (not left untyped): an untyped property fed both an array literal (SQL + // errors) and an explicit null (unrecognized-driver connect failure) reads back as + // a corrupted Mixed — `$e->errorInfo === null` returns the wrong answer, `[0]` will + // not index, and var_dump SIGSEGVs — because the Mixed slot loses its type tag + // across the heterogeneous call sites. The explicit `?array` gives the checker one + // coherent representation and keeps the null "no structured info" case (a + // connection-open failure with no server-reported SQLSTATE). + public ?array $errorInfo = null; + private string $sqlStateCode = ""; + + // F-SURF-11: the previous exception in the chain. php-src keeps this in the base + // Exception's private slot. elephc stores it here because the compiler-owned base + // Throwable layout has no previous slot; PDOException's getPrevious() is deliberately + // dispatched to the PHP method below instead of the generic null intrinsic. + public ?Throwable $previous = null; + + // F-SURF-10/F-SURF-11: the public constructor matches inherited Exception. Structured + // driver metadata is populated only through the private factory below, which the + // checker exposes to PDO/PDOStatement prelude methods as an internal friend channel. + // php-src stores the SQLSTATE string in the inherited code slot. elephc's base + // Exception slot is integer-only, so this class keeps the SQLSTATE in a dedicated + // string property and dispatches getCode() through the PDOException method below. + // The base integer slot still records errorInfo[1] for internal compatibility. + public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null) { + // The built-in Exception constructor is a checker-synthesized method with + // no linkable symbol, so `parent::__construct()` cannot be called; the + // public `$message` property (see getMessage()) is assigned directly. + $this->message = $message; + $this->code = $code; + $this->previous = $previous; + } + + private static function __elephcFromErrorInfo(string $message, ?array $errorInfo = null, ?Throwable $previous = null): PDOException { + $_error = new PDOException($message, 0, $previous); + $_error->errorInfo = $errorInfo; + // Keep both PDO's SQLSTATE code and the bridge's native integer code. is_array() + // narrowing is used because errorInfo is nullable at connection-failure sites. + if (is_array($errorInfo)) { + if (count($errorInfo) > 0) { + $_sqlState = $errorInfo[0]; + if (is_string($_sqlState)) { + $_error->sqlStateCode = (string) $_sqlState; } - $this->attributes[$_iattr] = $_val; } - } - // SQLite ignores credentials. For PostgreSQL and MySQL, the user/password - // may be passed as the PDO constructor arguments (PHP-style); fold them - // into the DSN's `key=value` list, where the bridge parses them (a `user=` - // / `password=` already in the DSN is overridden by the explicit argument). - $_dsn = $dsn; - if (str_starts_with($dsn, "pgsql:") || str_starts_with($dsn, "mysql:")) { - if ($username !== null) { - $_dsn = $_dsn . ";user=" . $username; - } - if ($password !== null) { - $_dsn = $_dsn . ";password=" . $password; + if (count($errorInfo) > 1) { + $_driverCode = $errorInfo[1]; + if (is_int($_driverCode)) { + $_error->code = (int) $_driverCode; + } } } - $this->conn = elephc_pdo_open_persistent($_dsn, $this->persistent ? 1 : 0); - if ($this->conn < 0) { - throw new PDOException(elephc_pdo_last_open_error()); - } + return $_error; } - private function fail(string $message): void { - // Apply the current error mode to a failed operation. EXCEPTION throws; - // WARNING writes to stderr and lets the caller return its failure value; - // SILENT is quiet and the caller returns its failure value. - if ($this->errMode == 2) { - throw new PDOException($message); - } - if ($this->errMode == 1) { - fwrite(STDERR, "PDO error: " . $message . "\n"); + public function getCode(): string|int { + if ($this->sqlStateCode !== "") { + return $this->sqlStateCode; } + return $this->code; } - public function setAttribute(int $attribute, $value): bool { - if ($attribute == 3) { - $this->errMode = (int) $value; - } elseif ($attribute == 12) { - $this->persistent = (bool) $value; - } - $this->attributes[$attribute] = $value; - return true; + public function getPrevious(): ?Throwable { + return $this->previous; } +} - public function getAttribute(int $attribute): mixed { - if ($attribute == 3) { - return $this->errMode; - } - if ($attribute == 12) { - return $this->persistent; - } - if ($attribute == 16) { - return elephc_pdo_driver_name($this->conn); +// Compiler-owned wrapper behind Pdo\Sqlite::openBlob(). The native bridge keeps +// the database handle and performs bounded binary-safe fixed-size operations; this +// PHP object owns only the independently seekable cursor and current cell size. +final class __ElephcPDOSqliteBlobStream { + private static bool $registered = false; + private static int $pendingConn = 0; + private static string $pendingTable = ""; + private static string $pendingColumn = ""; + private static int $pendingRowid = 0; + private static string $pendingDbname = "main"; + private static int $pendingSize = 0; + private static bool $pendingWritable = false; + + private int $conn = 0; + private string $table = ""; + private string $column = ""; + private int $rowid = 0; + private string $dbname = "main"; + private int $size = 0; + private int $position = 0; + private bool $writable = false; + + public static function create(int $conn, string $table, string $column, int $rowid, string $dbname, int $flags): mixed { + $_size = elephc_pdo_blob_size($conn, $table, $column, $rowid, $dbname); + if ($_size < 0) { + return false; } - if (isset($this->attributes[$attribute])) { - return $this->attributes[$attribute]; + if (!self::$registered) { + self::$registered = stream_wrapper_register("elephcpdosqliteblob", self::class); + if (!self::$registered) { + return false; + } } - return null; + self::$pendingConn = $conn; + self::$pendingTable = $table; + self::$pendingColumn = $column; + self::$pendingRowid = $rowid; + self::$pendingDbname = $dbname; + self::$pendingSize = $_size; + self::$pendingWritable = (($flags & 2) !== 0 && ($flags & 1) === 0); + return fopen("elephcpdosqliteblob://open", self::$pendingWritable ? "r+" : "r"); } - public function exec(string $statement): int|bool { - $_affected = elephc_pdo_exec($this->conn, $statement); - if ($_affected < 0) { - $this->fail(elephc_pdo_errmsg($this->conn)); - return false; - } - return $_affected; + public function stream_open($path, $mode, $options, &$openedPath): bool { + $_unusedPath = $path; + $_unusedMode = $mode; + $_unusedOptions = $options; + $this->conn = self::$pendingConn; + $this->table = self::$pendingTable; + $this->column = self::$pendingColumn; + $this->rowid = self::$pendingRowid; + $this->dbname = self::$pendingDbname; + $this->size = self::$pendingSize; + $this->writable = self::$pendingWritable; + $this->position = 0; + return true; } - public function prepare(string $query): PDOStatement|bool { - $_handle = elephc_pdo_prepare($this->conn, $query); - if ($_handle < 0) { - $this->fail(elephc_pdo_errmsg($this->conn)); - return false; + public function stream_read(int $count): string { + if ($count <= 0 || $this->position >= $this->size) { + return ""; + } + $_length = elephc_pdo_blob_read_at($this->conn, $this->table, $this->column, $this->rowid, $this->dbname, $this->position, $count); + if ($_length <= 0) { + return ""; } - return new PDOStatement($_handle, $this->conn, $this->errMode); + $_chunk = __elephc_ptr_read_string(elephc_pdo_blob_data_ptr(), $_length); + $this->position = $this->position + $_length; + return $_chunk; } - public function query(string $query): PDOStatement|bool { - $_statement = $this->prepare($query); - if ($_statement === false) { - return false; + public function stream_write(string $chunk): int { + if (!$this->writable) { + return -1; } - if ($_statement->execute() === false) { - return false; + $_count = strlen($chunk); + if ($this->position + $_count > $this->size) { + return -1; } - return $_statement; + $_written = elephc_pdo_blob_write_at($this->conn, $this->table, $this->column, $this->rowid, $this->dbname, $this->position, $chunk, $_count); + if ($_written !== $_count) { + return -1; + } + $this->position = $this->position + $_written; + return $_written; } - public function lastInsertId(?string $name = null): string { - // The name is a sequence for PostgreSQL (`currval($name)`); SQLite - // ignores it and returns the last rowid. - return (string) elephc_pdo_last_insert_id($this->conn, $name ?? ""); + public function stream_tell(): int { + return $this->position; } - public function beginTransaction(): bool { - if (elephc_pdo_begin($this->conn) != 1) { - $this->fail(elephc_pdo_errmsg($this->conn)); - return false; - } - return true; + public function stream_eof(): bool { + return $this->position >= $this->size; } - public function commit(): bool { - if (elephc_pdo_commit($this->conn) != 1) { - $this->fail(elephc_pdo_errmsg($this->conn)); + public function stream_seek(int $offset, int $whence): bool { + $_size = $this->size; + if ($whence === 0) { + $_target = $offset; + } elseif ($whence === 1) { + $_target = $this->position + $offset; + } elseif ($whence === 2) { + $_target = $_size + $offset; + } else { return false; } - return true; - } - - public function rollBack(): bool { - if (elephc_pdo_rollback($this->conn) != 1) { - $this->fail(elephc_pdo_errmsg($this->conn)); + if ($_target < 0) { + $this->position = 0; + return false; + } + if ($_target > $_size) { + $this->position = $_size; return false; } + $this->position = $_target; return true; } - public function errorCode(): string { - // The driver's native result code as a string. This is the native code, - // not a 5-character SQLSTATE (see errorInfo()): no supported driver's - // client library exposes SQLSTATEs here. - return (string) elephc_pdo_errcode($this->conn); + public function stream_stat(): array { + return ["size" => $this->size]; } - public function errorInfo(): array { - // PHP's errorInfo() is [SQLSTATE, driver-specific code, message]. The - // client libraries used here do not surface real 5-character SQLSTATEs - // (SQLite and MySQL expose native integer codes; the PostgreSQL client - // surfaces only a message, reported as a generic code), so the first - // element mirrors the native driver code as a string, not a true - // SQLSTATE. - $_code = elephc_pdo_errcode($this->conn); - return [(string) $_code, $_code, elephc_pdo_errmsg($this->conn)]; - } - - public function quote(string $string, int $type = 2): string { - // SQLite-style string-literal quoting for every driver: wrap in single - // quotes and double any embedded single quote. The $type argument is - // accepted for PHP signature compatibility but ignored. This is not - // driver-aware (e.g. it does not apply MySQL backslash escaping), so - // prefer prepared statements — the recommended path for all drivers. - $_unused = $type; - return "'" . str_replace("'", "''", $string) . "'"; + public function stream_flush(): bool { + return true; } - public function __destruct() { - // Release the bridge connection when the PDO object is collected. The - // bridge finalizes the connection's remaining statements before closing, - // and treats an already-closed handle as a no-op, so the order relative - // to any surviving PDOStatement destructors does not matter. - elephc_pdo_close($this->conn); - } + public function stream_close(): void {} } -class PDOStatement implements Iterator { - private int $stmt; - private int $conn; - private int $errMode; - private int $fetchMode; - private $fetchTarget; - private array $boundParams; - private array $boundValues; - private array $boundTypes; - private int $fetchColumn; - private int $rowCount; - private $iterRow; - private int $iterKey; +// Compiler-owned wrapper behind Pdo\Pgsql::lobOpen(). It keeps only the cursor and +// size locally: reads fetch bounded `lo_get` slices and writes patch bounded `lo_put` +// slices, so memory usage follows the caller's chunk size rather than the whole LOB. +// PostgreSQL itself preserves sparse seek/extension and zero-fill semantics. +final class __ElephcPDOPgsqlLobStream { + private static bool $registered = false; + private static int $pendingConn = 0; + private static string $pendingOid = ""; + private static int $pendingSize = 0; + private static bool $pendingWritable = false; + private static ?PDO $pendingOwner = null; - public function __construct(int $handle, int $connection, int $errMode = 2) { - $this->stmt = $handle; - $this->conn = $connection; - $this->errMode = $errMode; - $this->fetchMode = 4; - $this->fetchTarget = null; - $this->boundParams = []; - $this->boundValues = []; - $this->boundTypes = []; - $this->fetchColumn = 0; - $this->rowCount = 0; - // Initialized to null (not false) so the inferred property type widens to - // Mixed when rewind()/next() assign a fetched row; a bool initializer would - // pin the type to bool and coerce stored rows away. rewind() always runs - // before the first valid() check, so the initial value is never observed. - $this->iterRow = null; - $this->iterKey = 0; - } + private int $conn = 0; + private string $oid = ""; + private int $size = 0; + private int $position = 0; + private bool $writable = false; + private ?PDO $owner = null; - private function fail(string $message): void { - if ($this->errMode == 2) { - throw new PDOException($message); + public static function create(PDO $owner, int $conn, string $oid, string $mode): mixed { + if (!$owner->inTransaction()) { + return false; } - if ($this->errMode == 1) { - fwrite(STDERR, "PDO error: " . $message . "\n"); + $_size = elephc_pdo_lob_size($conn, $oid); + if ($_size < 0) { + return false; } - } - - public function setFetchMode(int $mode, mixed $classOrColumn = null): bool { - $this->fetchMode = $mode; - if ($mode == 7 && $classOrColumn !== null) { - $this->fetchColumn = (int) $classOrColumn; - } elseif (($mode == 8 || $mode == 9) && $classOrColumn !== null) { - $this->fetchTarget = $classOrColumn; + if (!self::$registered) { + self::$registered = stream_wrapper_register("elephcpdopgsqllob", self::class); + if (!self::$registered) { + return false; + } } - return true; + self::$pendingConn = $conn; + self::$pendingOid = $oid; + self::$pendingSize = $_size; + self::$pendingWritable = (strpos($mode, "+") !== false || strpos($mode, "w") !== false); + self::$pendingOwner = $owner; + return fopen("elephcpdopgsqllob://open", self::$pendingWritable ? "r+" : "r"); } - public function bindValue($parameter, $value, int $type = 2): bool { - // Resolve the 1-based slot index now and record it. The named-placeholder - // lookup must not be interleaved with value binds in execute()'s loop: a - // loop body that branches between "lookup index" and "no lookup" corrupts - // a sibling bind in generated code. Recording resolved int slots keeps - // execute()'s bind loop uniform. - if (is_int($parameter)) { - $_slot = (int) $parameter; - } else { - $_slot = (int) elephc_pdo_bind_parameter_index($this->stmt, (string) $parameter); - } - $this->boundParams[] = $_slot; - $this->boundValues[] = $value; - $this->boundTypes[] = $type; + public function stream_open($path, $mode, $options, &$openedPath): bool { + $_unusedPath = $path; + $_unusedMode = $mode; + $_unusedOptions = $options; + $this->conn = self::$pendingConn; + $this->oid = self::$pendingOid; + $this->size = self::$pendingSize; + $this->writable = self::$pendingWritable; + $this->owner = self::$pendingOwner; + $this->position = 0; return true; } - public function bindParam($parameter, $variable, int $type = 2): bool { - // Unlike PHP, the value is recorded now (not read by reference at execute - // time): bind right before execute(), or use bindValue(). - return $this->bindValue($parameter, $variable, $type); - } - - public function execute(?array $params = null): bool { - elephc_pdo_reset($this->stmt); - elephc_pdo_clear_bindings($this->stmt); - // Apply bindValue()/bindParam() bindings recorded since construction. - // Slots are already resolved to ints, so this loop never looks up an - // index (keeping the body uniform across positional and named binds). - $_count = count($this->boundParams); - for ($_i = 0; $_i < $_count; $_i++) { - $_slot = (int) $this->boundParams[$_i]; - $_value = $this->boundValues[$_i]; - $_btype = $this->boundTypes[$_i]; - if ($_btype == 0 || is_null($_value)) { - elephc_pdo_bind_null($this->stmt, $_slot); - } elseif ($_btype == 1 || $_btype == 5) { - elephc_pdo_bind_int($this->stmt, $_slot, (int) $_value); - } else { - elephc_pdo_bind_text($this->stmt, $_slot, (string) $_value); - } + public function stream_read(int $count): string { + if ($this->owner === null || !$this->owner->inTransaction()) { + return ""; } - // Apply this call's parameter array (positional ? and named :name). - if ($params !== null) { - foreach ($params as $_key => $_pv) { - if (is_int($_key)) { - $_idx = $_key + 1; - } else { - $_idx = elephc_pdo_bind_parameter_index($this->stmt, (string) $_key); - } - $_pslot = (int) $_idx; - if (is_int($_pv)) { - elephc_pdo_bind_int($this->stmt, $_pslot, (int) $_pv); - } elseif (is_bool($_pv)) { - elephc_pdo_bind_int($this->stmt, $_pslot, (int) $_pv); - } elseif (is_float($_pv)) { - elephc_pdo_bind_double($this->stmt, $_pslot, (float) $_pv); - } elseif (is_null($_pv)) { - elephc_pdo_bind_null($this->stmt, $_pslot); - } else { - elephc_pdo_bind_text($this->stmt, $_pslot, (string) $_pv); - } - } + if ($count <= 0 || $this->position >= $this->size) { + return ""; } - // A statement with no result columns (INSERT/UPDATE/DELETE/DDL) is run - // now; SELECT-style statements (column_count > 0) are stepped lazily by - // fetch() so the first row is not consumed here. - if (elephc_pdo_column_count($this->stmt) == 0) { - $_step = elephc_pdo_step($this->stmt); - if ($_step < 0) { - $this->fail(elephc_pdo_errmsg($this->conn)); - $this->rowCount = elephc_pdo_changes($this->conn); - return false; - } + $_requested = $count; + if ($this->position + $_requested > $this->size) { + $_requested = $this->size - $this->position; } - // Snapshot the affected-row count now, so rowCount() reports this - // statement's result even if another statement runs on the same - // connection afterward. The bridge's changes() is connection-wide, so - // reading it lazily in rowCount() would otherwise return a later - // statement's count (e.g. PostgreSQL/MySQL overwrite changes() with a - // SELECT's row count). - $this->rowCount = elephc_pdo_changes($this->conn); - return true; + $_length = elephc_pdo_lob_read_at($this->conn, $this->oid, $this->position, $_requested); + if ($_length < 0) { + return ""; + } + $_chunk = ""; + if ($_length > 0) { + $_chunk = __elephc_ptr_read_string(elephc_pdo_blob_data_ptr(), $_length); + } + $this->position = $this->position + strlen($_chunk); + return $_chunk; } - private function columnValue(int $index): mixed { - $_type = elephc_pdo_column_type($this->stmt, $index); - if ($_type == 1) { - return elephc_pdo_column_int($this->stmt, $index); - } elseif ($_type == 2) { - return elephc_pdo_column_double($this->stmt, $index); - } elseif ($_type == 5) { - return null; + public function stream_write(string $chunk): int { + if (!$this->writable || $this->owner === null || !$this->owner->inTransaction()) { + return -1; } - $_len = elephc_pdo_column_data_len($this->stmt, $index); - $_out = ""; - for ($_j = 0; $_j < $_len; $_j++) { - $_out = $_out . chr(elephc_pdo_column_data_byte($this->stmt, $index, $_j)); + $_count = strlen($chunk); + $_written = elephc_pdo_lob_write_at($this->conn, $this->oid, $this->position, $chunk, $_count); + if ($_written < 0) { + return -1; } - return $_out; + $this->position = $this->position + $_written; + if ($this->position > $this->size) { + $this->size = $this->position; + } + return $_written; } - private function assignColumns(mixed $object, int $count): mixed { - for ($_i = 0; $_i < $count; $_i++) { - $_value = $this->columnValue($_i); - $_name = elephc_pdo_column_name($this->stmt, $_i); - $object->{$_name} = $_value; - } - return $object; + public function stream_tell(): int { + return $this->position; } - public function fetch(int $mode = 0, mixed $classOrObject = null): mixed { - if ($mode == 0) { - $mode = $this->fetchMode; + public function stream_eof(): bool { + return $this->position >= $this->size; + } + + public function stream_seek(int $offset, int $whence): bool { + if ($this->owner === null || !$this->owner->inTransaction()) { + return false; } - $_rc = elephc_pdo_step($this->stmt); - if ($_rc < 0) { - $this->fail(elephc_pdo_errmsg($this->conn)); + if ($whence === 0) { + $_target = $offset; + } elseif ($whence === 1) { + $_target = $this->position + $offset; + } elseif ($whence === 2) { + $_target = $this->size + $offset; + } else { return false; } - if ($_rc == 0) { + if ($_target < 0) { + return false; + } + $this->position = $_target; + return true; + } + + public function stream_stat(): array { + return ["size" => $this->size]; + } + + public function stream_flush(): bool { + return true; + } + + public function stream_close(): void {} +} + +// -- elephc optional PDO_ODBC global type begin -- +const PDO_ODBC_TYPE = "unixODBC"; +// -- elephc optional PDO_ODBC global type end -- + +class PDO { + const FETCH_ASSOC = 2; + const FETCH_NUM = 3; + const FETCH_BOTH = 4; + const FETCH_OBJ = 5; + const FETCH_COLUMN = 7; + const FETCH_CLASS = 8; + const FETCH_INTO = 9; + const PARAM_NULL = 0; + const PARAM_INT = 1; + const PARAM_STR = 2; + const PARAM_BOOL = 5; + const ATTR_TIMEOUT = 2; + const ATTR_ERRMODE = 3; + const ATTR_PERSISTENT = 12; + const ATTR_DRIVER_NAME = 16; + const ERRMODE_SILENT = 0; + const ERRMODE_WARNING = 1; + const ERRMODE_EXCEPTION = 2; + const ERR_NONE = "00000"; + // Additional PHP 8.4 fetch-mode constants (base modes and OR-able flags). + const FETCH_DEFAULT = 0; + const FETCH_LAZY = 1; + const FETCH_BOUND = 6; + const FETCH_FUNC = 10; + const FETCH_NAMED = 11; + const FETCH_KEY_PAIR = 12; + const FETCH_GROUP = 0x10000; + const FETCH_UNIQUE = 0x30000; + const FETCH_CLASSTYPE = 0x40000; + const FETCH_SERIALIZE = 0x80000; + const FETCH_PROPS_LATE = 0x100000; + const FETCH_ORI_NEXT = 0; + const FETCH_ORI_PRIOR = 1; + const FETCH_ORI_FIRST = 2; + const FETCH_ORI_LAST = 3; + const FETCH_ORI_ABS = 4; + const FETCH_ORI_REL = 5; + // Parameter-type constants. + const PARAM_LOB = 3; + const PARAM_STMT = 4; + const PARAM_INPUT_OUTPUT = 0x80000000; + const PARAM_STR_NATL = 0x40000000; + const PARAM_STR_CHAR = 0x20000000; + // F-SURF-03: the parameter-lifecycle event constants. Their values are the + // DECLARATION ORDER of `enum pdo_param_event` in php-src's + // ext/pdo/php_pdo_driver.h, which is the only thing that fixes them (the enum + // carries no explicit values). They exist for userspace/native PDO *driver* + // authorship — a driver's `param_hook` is called once per event so it can + // allocate, rewrite, or free a bound parameter around each stage of a + // statement's life. elephc's bridge implements the drivers natively in Rust and + // exposes no param-hook seam to PHP, so these constants are entirely INERT here: + // they are declared purely so code that references PDO::PARAM_EVT_* (portable + // driver shims, test suites enumerating the class surface) still compiles. + const PARAM_EVT_ALLOC = 0; + const PARAM_EVT_FREE = 1; + const PARAM_EVT_EXEC_PRE = 2; + const PARAM_EVT_EXEC_POST = 3; + const PARAM_EVT_FETCH_PRE = 4; + const PARAM_EVT_FETCH_POST = 5; + const PARAM_EVT_NORMALIZE = 6; + // Driver/connection attribute constants (PHP 8.4 numeric values). + const ATTR_AUTOCOMMIT = 0; + const ATTR_PREFETCH = 1; + const ATTR_SERVER_VERSION = 4; + const ATTR_CLIENT_VERSION = 5; + const ATTR_SERVER_INFO = 6; + const ATTR_CONNECTION_STATUS = 7; + const ATTR_CASE = 8; + const ATTR_CURSOR_NAME = 9; + const ATTR_CURSOR = 10; + const ATTR_ORACLE_NULLS = 11; + const ATTR_STATEMENT_CLASS = 13; + const ATTR_FETCH_TABLE_NAMES = 14; + const ATTR_FETCH_CATALOG_NAMES = 15; + const ATTR_STRINGIFY_FETCHES = 17; + const ATTR_MAX_COLUMN_LEN = 18; + const ATTR_DEFAULT_FETCH_MODE = 19; + const ATTR_EMULATE_PREPARES = 20; + const ATTR_DEFAULT_STR_PARAM = 21; + const ATTR_DRIVER_SPECIFIC = 1000; + // Column-case, null-handling, and cursor-orientation constants. + const CASE_NATURAL = 0; + const CASE_UPPER = 1; + const CASE_LOWER = 2; + const NULL_NATURAL = 0; + const NULL_EMPTY_STRING = 1; + const NULL_TO_STRING = 2; + const CURSOR_FWDONLY = 0; + const CURSOR_SCROLL = 1; + // F-SQLT-01: php-src registers the SQLite driver constants on the BASE \PDO + // class as well as on Pdo\Sqlite (ext/pdo_sqlite/pdo_sqlite.c registers them + // against pdo_dbh_ce, in parallel with the modern class-scoped spellings added + // in 8.1) — `PDO::SQLITE_ATTR_OPEN_FLAGS` and friends are the pre-8.1 API + // surface a great deal of real-world code still uses. Same values as the + // Pdo\Sqlite constants further down; the two spellings are aliases, both live. + const SQLITE_DETERMINISTIC = 2048; + const SQLITE_ATTR_OPEN_FLAGS = 1000; + const SQLITE_OPEN_READONLY = 1; + const SQLITE_OPEN_READWRITE = 2; + const SQLITE_OPEN_CREATE = 4; + // -- elephc optional PDO_DBLIB aliases begin -- + const DBLIB_ATTR_CONNECTION_TIMEOUT = 1000; + const DBLIB_ATTR_QUERY_TIMEOUT = 1001; + const DBLIB_ATTR_STRINGIFY_UNIQUEIDENTIFIER = 1002; + const DBLIB_ATTR_VERSION = 1003; + const DBLIB_ATTR_TDS_VERSION = 1004; + const DBLIB_ATTR_SKIP_EMPTY_ROWSETS = 1005; + const DBLIB_ATTR_DATETIME_CONVERT = 1006; + // -- elephc optional PDO_DBLIB aliases end -- + // -- elephc optional PDO_FIREBIRD aliases begin -- + const FB_ATTR_DATE_FORMAT = 1000; + const FB_ATTR_TIME_FORMAT = 1001; + const FB_ATTR_TIMESTAMP_FORMAT = 1002; + // -- elephc optional PDO_FIREBIRD aliases end -- + // -- elephc optional PDO_ODBC aliases begin -- + const ODBC_ATTR_USE_CURSOR_LIBRARY = 1000; + const ODBC_ATTR_ASSUME_UTF8 = 1001; + const ODBC_SQL_USE_IF_NEEDED = 0; + const ODBC_SQL_USE_ODBC = 1; + const ODBC_SQL_USE_DRIVER = 2; + // -- elephc optional PDO_ODBC aliases end -- + // -- elephc optional PDO_IBM aliases begin -- + const SQL_ATTR_INFO_USERID = 1281; + const SQL_ATTR_INFO_ACCTSTR = 1282; + const SQL_ATTR_INFO_APPLNAME = 1283; + const SQL_ATTR_INFO_WRKSTNNAME = 1284; + const SQL_ATTR_USE_TRUSTED_CONTEXT = 2561; + const SQL_ATTR_TRUSTED_CONTEXT_USERID = 2562; + const SQL_ATTR_TRUSTED_CONTEXT_PASSWORD = 2563; + // -- elephc optional PDO_IBM aliases end -- + // -- elephc optional PDO_SQLSRV constants begin -- + const SQLSRV_ATTR_ENCODING = 1000; + const SQLSRV_ATTR_QUERY_TIMEOUT = 1001; + const SQLSRV_ATTR_DIRECT_QUERY = 1002; + const SQLSRV_ATTR_CURSOR_SCROLL_TYPE = 1003; + const SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE = 1004; + const SQLSRV_ATTR_FETCHES_NUMERIC_TYPE = 1005; + const SQLSRV_ATTR_FETCHES_DATETIME_TYPE = 1006; + const SQLSRV_ATTR_FORMAT_DECIMALS = 1007; + const SQLSRV_ATTR_DECIMAL_PLACES = 1008; + const SQLSRV_ATTR_DATA_CLASSIFICATION = 1009; + const SQLSRV_PARAM_OUT_DEFAULT_SIZE = -1; + const SQLSRV_ENCODING_DEFAULT = 1; + const SQLSRV_ENCODING_BINARY = 2; + const SQLSRV_ENCODING_SYSTEM = 3; + const SQLSRV_ENCODING_UTF8 = 65001; + const SQLSRV_CURSOR_STATIC = 3; + const SQLSRV_CURSOR_DYNAMIC = 2; + const SQLSRV_CURSOR_KEYSET = 1; + const SQLSRV_CURSOR_BUFFERED = 42; + const SQLSRV_TXN_READ_UNCOMMITTED = "READ_UNCOMMITTED"; + const SQLSRV_TXN_READ_COMMITTED = "READ_COMMITTED"; + const SQLSRV_TXN_REPEATABLE_READ = "REPEATABLE_READ"; + const SQLSRV_TXN_SERIALIZABLE = "SERIALIZABLE"; + const SQLSRV_TXN_SNAPSHOT = "SNAPSHOT"; + // -- elephc optional PDO_SQLSRV constants end -- + // -- elephc optional PDO_OCI aliases begin -- + const OCI_ATTR_ACTION = 1000; + const OCI_ATTR_CLIENT_INFO = 1001; + const OCI_ATTR_CLIENT_IDENTIFIER = 1002; + const OCI_ATTR_MODULE = 1003; + const OCI_ATTR_CALL_TIMEOUT = 1004; + // -- elephc optional PDO_OCI aliases end -- + // -- elephc optional PDO_CUBRID constants begin -- + const CUBRID_ATTR_ISOLATION_LEVEL = 1000; + const CUBRID_ATTR_LOCK_TIMEOUT = 1001; + const CUBRID_ATTR_MAX_STRING_LENGTH = 1002; + const TRAN_REP_CLASS_COMMIT_INSTANCE = 4; + const TRAN_REP_CLASS_REP_INSTANCE = 5; + const TRAN_SERIALIZABLE = 6; + const CUBRID_SCH_TABLE = 1; + const CUBRID_SCH_VIEW = 2; + const CUBRID_SCH_QUERY_SPEC = 3; + const CUBRID_SCH_ATTRIBUTE = 4; + const CUBRID_SCH_TABLE_ATTRIBUTE = 5; + const CUBRID_SCH_METHOD = 6; + const CUBRID_SCH_TABLE_METHOD = 7; + const CUBRID_SCH_METHOD_FILE = 8; + const CUBRID_SCH_SUPER_TABLE = 9; + const CUBRID_SCH_SUB_TABLE = 10; + const CUBRID_SCH_CONSTRAINT = 11; + const CUBRID_SCH_TRIGGER = 12; + const CUBRID_SCH_TABLE_PRIVILEGE = 13; + const CUBRID_SCH_COL_PRIVILEGE = 14; + const CUBRID_SCH_DIRECT_SUPER_TABLE = 15; + const CUBRID_SCH_PRIMARY_KEY = 16; + const CUBRID_SCH_IMPORTED_KEYS = 17; + const CUBRID_SCH_EXPORTED_KEYS = 18; + const CUBRID_SCH_CROSS_REFERENCE = 19; + const CUBRID_SCH_ATTR_WITH_SYNONYM = 20; + // -- elephc optional PDO_CUBRID constants end -- + const SQLITE_ATTR_READONLY_STATEMENT = 1001; + const SQLITE_ATTR_EXTENDED_RESULT_CODES = 1002; + + private int $conn; + private int $errMode; + private bool $persistent; + private array $attributes; + // php-src leaves the DBH error code uninitialized until a driver operation + // has run. This distinguishes a fresh connection (`errorCode() === null`, + // `errorInfo()[0] === ""`) from a successful operation (`"00000"`). + private bool $hasOperation; + private bool $inTxn; + private bool $autoCommit; + private int $defaultStrParam; + private int $defaultFetchMode; + // PDO::ATTR_STATEMENT_CLASS stores the canonical two-part configuration used by + // prepare()/query(): index 0 is the PDOStatement-derived class name and optional + // index 1 is the constructor-argument array. Keeping the optional index absent is + // observable through getAttribute() and distinguishes "no ctor args supplied" from + // an explicitly supplied empty array. + private array $statementClassConfig; + // P1-11 (best-effort): ATTR_STRINGIFY_FETCHES, threaded to each statement at + // prepare() time the same way $defaultFetchMode already is. This is a + // snapshot, not a live read of the connection's current value — a divergence + // already accepted for $defaultFetchMode, so a setAttribute() call after a + // statement is prepared does not retroactively affect it (real PHP re-checks + // the connection attribute on every fetch). + private bool $stringifyFetches; + // P2-e: ATTR_CASE (folds fetched column-name keys) and ATTR_ORACLE_NULLS + // (folds NULL<->"" in fetched scalar values), both threaded to each + // statement at prepare()/query() time the same way $defaultFetchMode / + // $stringifyFetches already are — a prepare()-time snapshot, not a live + // read of the connection's current value (the same accepted divergence). + private int $attrCase; + private int $oracleNulls; + // Driver protocol selection. MySQL follows php-src's emulated-by-default + // behavior; PostgreSQL defaults to native and can request its simple-query + // path through either ATTR_EMULATE_PREPARES or ATTR_DISABLE_PREPARES. + private bool $emulatePrepares; + private bool $disablePrepares; + // Operation label used to preserve php-src's active-method name when query() + // delegates preparation to prepare(). It is reset as soon as prepare() starts. + private string $prepareOperation; + // Roots callbacks registered through PHP 8.4's legacy + // PDO::sqliteCreate* driver-extension methods. + protected array $pdoUdfCallbacks; + + // F-CORE-11: php-src supports an INDIRECT DSN — `new PDO("uri:")` reads the + // real DSN from the FIRST LINE of the referenced stream (`dsn_from_uri`, + // pdo_dbh.c:208-220, called from the constructor at pdo_dbh.c:346-358, ahead of the + // driver lookup), so a credentials-bearing DSN can live outside the source tree. + // This prelude had no `uri:` handling at all, so such a DSN reached the bridge + // verbatim and failed as an unknown driver. Returns the DSN unchanged when it + // carries no `uri:` prefix, so every caller pipes its raw argument through this + // unconditionally (and re-running it on an already-resolved DSN is a no-op, which is + // what lets the driver subclasses resolve first and still hand the result to + // parent::__construct()). + // + // Two divergences, both forced by elephc's I/O surface rather than chosen: + // (1) php-src opens the URI through the full stream-wrapper stack. elephc's fopen() + // has no `file://` wrapper (its wrapper table — src/codegen/lower_inst/builtins/ + // io.rs — covers php://, data://, ftp://, phar://, http://, compress.*:// and + // nothing else), so the `file://` scheme — the very one PHP's own documentation + // uses for this feature — is stripped here and the remainder opened as a plain + // path. Any other scheme is handed to fopen() as-is and simply fails to open, + // which lands on the same error below. + // (2) php-src's php_stream_get_line KEEPS the trailing newline; it is trimmed here, + // since a DSN carrying a stray "\n" reaches the driver parsers as a garbage + // trailing key. + // + // (3) php-src DEPRECATED this whole DSN form ("Looking up the DSN from a URI is + // deprecated due to possible security concerns with DSNs coming from remote + // URIs") and emits an E_DEPRECATED alongside the successful lookup. elephc has no + // deprecation-diagnostic channel, so the notice is documented here instead of + // raised; the feature still works, exactly as it still does in PHP. + // + // The two failure messages and their EXCEPTION CLASS were verified against a real + // PHP 8.5.6 CLI rather than read off the C source, because php-src raises them with + // `zend_argument_error(pdo_exception_ce, 1, …)` — an ARGUMENT-ERROR MESSAGE SHAPE + // ("…(): Argument #1 ($dsn) must be …") thrown as a **PDOException**, NOT as a + // ValueError. Reading only the `zend_argument_*` call would have produced the wrong + // class here: + // unreadable URI / empty first line -> "…must be a valid data source URI" + // first line with no colon in it -> "…must be a valid data source name (via URI)" + protected static function resolveDsnUri(string $dsn, string $operation): string { + if (!str_starts_with($dsn, "uri:")) { + return $dsn . ""; + } + $_uri = substr($dsn, 4); + if (str_starts_with($_uri, "file://")) { + $_uri = substr($_uri, 7); + } + $_uriHandle = fopen($_uri, "rb"); + if ($_uriHandle === false) { + throw new PDOException($operation . "(): Argument #1 (\$dsn) must be a valid data source URI"); + } + $_uriLine = fgets($_uriHandle); + fclose($_uriHandle); + if ($_uriLine === false) { + // EOF on the very first read: the stream opened but is empty, which php-src + // reports identically to an unopenable one (dsn_from_uri returns NULL for both). + throw new PDOException($operation . "(): Argument #1 (\$dsn) must be a valid data source URI"); + } + // Explicit cast: the checker does not narrow fgets()'s `string|false` out of the + // `=== false` guard above (the same accepted gap copyFromFile() casts around for + // file_get_contents). + $_resolved = rtrim((string) $_uriLine, "\r\n"); + if (strpos($_resolved, ":") === false) { + throw new PDOException($operation . "(): Argument #1 (\$dsn) must be a valid data source name (via URI)"); + } + return $_resolved; + } + + // PHP resolves a colonless constructor/factory DSN through the startup + // configuration key `pdo.dsn.` before URI handling and driver dispatch. + // The bridge reads PHPRC/php.ini and PHP_INI_SCAN_DIR fragments at runtime so + // aliases remain deployment configuration rather than compiler constants. + protected static function resolveDsnAlias(string $dsn, string $operation): string { + if (strpos($dsn, ":") !== false) { + return $dsn . ""; + } + $_key = "pdo.dsn." . $dsn; + if (elephc_pdo_ini_dsn_defined($dsn) !== 1) { + throw new PDOException($operation . "(): Argument #1 (\$dsn) must be a valid data source name"); + } + $_resolved = elephc_pdo_ini_dsn_value($dsn); + if (strpos($_resolved, ":") === false) { + throw new PDOException("invalid data source name (via INI: " . $_key . ")"); + } + return $_resolved; + } + + // F-CORE-13: php-src validates the DSN in two steps, with two DIFFERENT messages, + // both before any driver is asked to connect (pdo_dbh.c:346-372): + // 1. no colon at all -> the ARGUMENT-ERROR message shape + // "PDO::__construct(): Argument #1 ($dsn) must be a valid data source name"; + // 2. a colon but no driver registered for the prefix -> the BARE message + // "could not find driver" (php-src deliberately keeps the DSN out of that text: + // it may carry a password). + // Neither existed here originally: the constructor let the bridge fail the open + // while PDO::connect() threw php-src's bare text, so one failure had two messages + // inside one class and a colonless DSN got neither. + // + // BOTH are PDOExceptions — VERIFIED against a real PHP 8.5.6 CLI, and worth stating + // because the C source misleads: case 1 is raised with + // `zend_argument_error(pdo_exception_ce, 1, …)`, whose first parameter is the + // exception class entry, so it produces an argument-error MESSAGE SHAPE thrown as a + // **PDOException** — NOT a ValueError, despite reading like every other + // zend_argument_* call site in the tree. `get_class($e)` on a real + // `new PDO("nocolon")` is "PDOException". + // + // Divergence: the message names `PDO::__construct()` even when the call came through + // a driver subclass (php-src names the called scope). elephc has no late static + // binding — `static::` lowers to the DEFINING class (src/ir_lower/expr/mod.rs:9654) — + // so the called scope is not observable from here. + protected function checkDsnIsSupported(string $dsn): void { + // No driver attempted a connection here, so the public PHP-compatible constructor + // is used directly and errorInfo remains null. + if (strpos($dsn, ":") === false) { + throw new PDOException("PDO::__construct(): Argument #1 (\$dsn) must be a valid data source name"); + } + $_driver = substr($dsn, 0, (int) strpos($dsn, ":")); + $_driverFound = false; + $_driverCount = elephc_pdo_available_driver_count(); + for ($_driverIndex = 0; $_driverIndex < $_driverCount; $_driverIndex++) { + if (elephc_pdo_available_driver_name($_driverIndex) === $_driver) { + $_driverFound = true; + break; + } + } + if (!$_driverFound) { + throw new PDOException("could not find driver"); + } + } + + // F-CORE-01: php-src refuses to open a FOREIGN DSN through a driver-specific + // subclass — `create_driver_specific_pdo_object` (pdo_dbh.c:222-299) compares the + // DSN's driver against the called scope and throws when they differ. elephc's three + // subclasses forwarded blindly (and Pdo\Mysql had no constructor at all), so + // `new Pdo\Sqlite("mysql:host=…")` happily returned a Pdo\Sqlite object holding a + // live MySQL connection — an object whose class lies about what it is, and whose + // SQLite-only methods (openBlob, createFunction, …) then fail deep in the bridge. + // + // Called from each subclass constructor BEFORE parent::__construct(), i.e. before + // any connection attempt, which is where php-src runs it too. A DSN whose prefix is + // no driver this bridge knows is deliberately NOT rejected here: that is a different + // failure with a different message, owned by checkDsnIsSupported() a moment later. + // + // Divergence (unfixable without late static binding, see checkDsnIsSupported()): + // php-src throws the same error for the STATIC form, `Pdo\Sqlite::connect("mysql:…")`, + // with "connect()" swapped in for "__construct()". PDO::connect() is a plain + // inherited static here and cannot see which subclass it was called through, so that + // spelling still dispatches on the DSN prefix alone. + protected function checkDriverSubclassDsn(string $dsn, string $calledClass, string $expectedDriver): void { + if (str_starts_with($dsn, $expectedDriver . ":")) { + return; + } + $_dsnDriver = ""; + $_dsnClass = ""; + if (str_starts_with($dsn, "sqlite:")) { + $_dsnDriver = "sqlite"; + $_dsnClass = "Pdo\\Sqlite"; + } elseif (str_starts_with($dsn, "mysql:")) { + $_dsnDriver = "mysql"; + $_dsnClass = "Pdo\\Mysql"; + } elseif (str_starts_with($dsn, "pgsql:")) { + $_dsnDriver = "pgsql"; + $_dsnClass = "Pdo\\Pgsql"; + } elseif (str_starts_with($dsn, "dblib:")) { + $_dsnDriver = "dblib"; + $_dsnClass = "Pdo\\Dblib"; + } elseif (str_starts_with($dsn, "firebird:")) { + $_dsnDriver = "firebird"; + $_dsnClass = "Pdo\\Firebird"; + } elseif (str_starts_with($dsn, "odbc:")) { + $_dsnDriver = "odbc"; + $_dsnClass = "Pdo\\Odbc"; + } + // -- elephc optional PDO_IBM subclass guard begin -- + elseif (str_starts_with($dsn, "ibm:")) { + $_dsnDriver = "ibm"; + $_dsnClass = "Pdo\\Ibm"; + } + // -- elephc optional PDO_IBM subclass guard end -- + // -- elephc optional PDO_OCI subclass guard begin -- + elseif (str_starts_with($dsn, "oci:")) { + $_dsnDriver = "oci"; + $_dsnClass = "PDO"; + } + // -- elephc optional PDO_OCI subclass guard end -- + // -- elephc optional PDO_SQLSRV subclass guard begin -- + elseif (str_starts_with($dsn, "sqlsrv:")) { + $_dsnDriver = "sqlsrv"; + $_dsnClass = "PDO"; + } + // -- elephc optional PDO_SQLSRV subclass guard end -- + if ($_dsnDriver === "") { + return; + } + throw new PDOException($calledClass . "::__construct() cannot be used for connecting to the \"" . $_dsnDriver . "\" driver, either call " . $_dsnClass . "::__construct() or PDO::__construct() instead"); + } + + public function __construct(string $dsn, ?string $username = null, #[\SensitiveParameter] ?string $password = null, ?array $options = null) { + // F-CORE-11 / F-CORE-13: resolve an indirect `uri:` DSN and validate the result + // FIRST — php-src does both ahead of the options loop and the driver connect + // (pdo_dbh.c:346-372). Every later DSN test in this method reads $_dsn, never the + // raw $dsn parameter, which for a `uri:` DSN still says "uri:…". + $_operation = get_class($this) . "::__construct"; + $_dsn = self::resolveDsnAlias($dsn, $_operation); + $_dsn = self::resolveDsnUri($_dsn, $_operation); + $this->checkDsnIsSupported($_dsn); + $this->errMode = 2; + $this->persistent = false; + $this->attributes = []; + $this->hasOperation = false; + $this->inTxn = false; + $this->autoCommit = true; + $this->defaultStrParam = 0x20000000; + $this->defaultFetchMode = 4; + $this->statementClassConfig = ["PDOStatement"]; + $this->stringifyFetches = false; + $this->attrCase = 0; + // PDO_INFORMIX and PDO_IBM set `desired_case = PDO_CASE_UPPER` in their + // handle factories, so natural fetches expose upper-cased identifiers. + if (str_starts_with($_dsn, "informix:") || str_starts_with($_dsn, "ibm:")) { + $this->attrCase = 1; + } + $this->oracleNulls = 0; + $this->emulatePrepares = substr($_dsn, 0, 6) === "mysql:" || substr($_dsn, 0, 6) === "dblib:"; + $this->disablePrepares = false; + $this->prepareOperation = "PDO::prepare"; + $this->pdoUdfCallbacks = []; + // P1-10: Pdo\Sqlite::ATTR_OPEN_FLAGS, read from $options here and applied + // at the open call below. Its numeric value (1000) is PDO_ATTR_DRIVER_SPECIFIC + // (see self::ATTR_DRIVER_SPECIFIC) — the same value MySQL/PostgreSQL use for + // their own first driver-specific attribute, but this is harmless: the bridge + // only consults $_openFlags for a `sqlite:` DSN and ignores it otherwise. + $_openFlags = 0; + // P1-9: Pdo\Mysql::ATTR_INIT_COMMAND (one SQL statement + // run right after authentication), read from $options here and applied at + // the open call below. Its numeric value (1002) collides with + // Pdo\Sqlite::ATTR_EXTENDED_RESULT_CODES, harmlessly: the bridge only + // consults $_myInitCommand for a `mysql:` DSN and ignores it otherwise. + $_myInitCommand = ""; + // Pdo\Mysql::ATTR_SSL_* (1007/1008/1009/1014): read here into a packed + // "ca=…;cert=…;key=…;verify=0|1" string ($_mySslConfig below) that the + // bridge applies to the mysql: rustls TLS backend. These numeric values do + // not collide with any sqlite:/pgsql: driver-specific constant, and the + // bridge only consults $_mySslConfig for a mysql: DSN, so they stay inert + // for the other drivers. ATTR_SSL_CAPATH (1010) is adapted to a temporary + // multi-PEM CA bundle; ATTR_SSL_CIPHER (1011) is forwarded to the driver-option + // parser and fails explicitly because rustls exposes no PDO cipher-string mapping. + // $_mySslVerify stays -1 ("unset") until an + // explicit ATTR_SSL_VERIFY_SERVER_CERT is seen. + $_mySslCa = ""; + $_mySslCert = ""; + $_mySslKey = ""; + $_mySslVerify = -1; + // F-MY-06: Pdo\Mysql::ATTR_FOUND_ROWS (1005), threaded to the bridge's connect + // path below. F-CORE-16: the user-supplied ATTR_PERSISTENT pool key ("" = the + // plain boolean-persistent pool). Both are read from $options in the loop below. + $_myFoundRows = 0; + $_myBufferedQuery = 1; + $_myLocalInfile = 0; + $_myLocalInfileDirectory = ""; + $_myCompress = 0; + $_myIgnoreSpace = 0; + $_myMultiStatements = 1; + $_mySslCapath = ""; + $_mySslCipher = ""; + $_myServerPublicKey = ""; + $_persistentKey = ""; + $_statementClassConfigured = false; + // Constructor options affect the connection that is opened below, so + // apply them before the bridge sees the DSN. In particular, + // ATTR_PERSISTENT selects the bridge's process-local DSN pool. + if ($options !== null) { + foreach ($options as $_attr => $_val) { + // PDO_CUBRID uniquely forwards string-keyed constructor options to + // CCI's URL query string. Preserve those keys before the common PDO + // numeric-attribute path casts the key to an integer. + if (str_starts_with($_dsn, "cubrid:") && is_string($_attr)) { + if (!is_string($_val)) { + throw new PDOException("Invalid CUBRID connection option"); + } + $_dsn = $_dsn . ";" . ((string) $_attr) . "=" . ((string) $_val); + continue; + } + $_iattr = (int) $_attr; + if ($_iattr == 0) { + // Only pdo_mysql exposes a live AUTOCOMMIT hook; retain the + // normalized option until the connection is open below. + $this->autoCommit = $this->attrBoolValue($_val); + } elseif ($_iattr == 3) { + // P1-h: same ATTR_ERRMODE value validation as setAttribute() below — + // a bad mode must not silently take effect via the constructor either. + // F-CORE-03: including the SHAPE check (attrIntValue), which php-src + // runs on the constructor's options array through the very same + // pdo_get_long_param() path — this loop had the identical blind-cast + // hole, so `new PDO($dsn, null, null, [PDO::ATTR_ERRMODE => "banana"])` + // used to open the connection in ERRMODE_SILENT. + $_ctorErrMode = $this->attrIntValue($_val); + $this->checkErrMode($_ctorErrMode); + $this->errMode = $_ctorErrMode; + } elseif ($_iattr == 12) { + // F-CORE-16: the CONSTRUCTOR's ATTR_PERSISTENT does NOT go through + // pdo_get_bool_param — pdo_dbh.c:389-404 special-cases it entirely, in + // two arms this branch mirrors one for one: + // * a NON-NUMERIC, NON-EMPTY STRING is a user-supplied POOL KEY: the + // connection is persistent AND that string joins the DSN in the + // persistent pool's hash key, so two persistent connections to one + // DSN under different keys stay DISTINCT handles (that separation is + // the entire point of the named form); + // * anything else is `is_persistent = zval_get_long(v) ? 1 : 0` — a + // plain NUMERIC COERCION, so a numeric string, an empty string, a + // float and a bool all just coerce, and NONE of them is an error. + // Both arms were wrong here: this used to call attrBoolValue(), which + // threw the pool key away AND raised a spurious TypeError for every + // string form. Verified against a real PHP 8.5.6 CLI: + // ATTR_PERSISTENT => "keyA" gives persistent true; => "0" and => "" both + // give false, with no error raised for either. + if (is_string($_val) && ((string) $_val) !== "" && !is_numeric((string) $_val)) { + $this->persistent = true; + $_persistentKey = (string) $_val; + } else { + $this->persistent = ((int) $_val) != 0; + } + } elseif ($_iattr == 13) { + $this->statementClassConfig = $this->validateStatementClassConfig($_val, false); + $_statementClassConfigured = true; + } elseif ($_iattr == 19) { + // P1-h: same ATTR_DEFAULT_FETCH_MODE validation as setAttribute() below. + $_ctorFetchMode = $this->attrIntValue($_val); + $this->checkDefaultFetchMode($_ctorFetchMode); + $this->defaultFetchMode = $_ctorFetchMode; + } elseif ($_iattr == 17) { + $this->stringifyFetches = $this->attrBoolValue($_val); + } elseif ($_iattr == 21 && (str_starts_with($_dsn, "mysql:") || str_starts_with($_dsn, "dblib:") || str_starts_with($_dsn, "sqlsrv:"))) { + $_defaultStringType = $this->attrIntValue($_val); + $this->defaultStrParam = ($_defaultStringType == 0x40000000) ? 0x40000000 : 0x20000000; + } elseif ($_iattr == 20) { + // DB-Library has no native prepare API. php-src therefore + // reports emulation as permanently enabled even when this + // constructor option asks for native prepares. + if (str_starts_with($_dsn, "dblib:")) { + $this->emulatePrepares = true; + } else { + $this->emulatePrepares = $this->attrBoolValue($_val); + } + } elseif ($_iattr == 8) { + // P2-e: same ATTR_CASE value validation as setAttribute() below. + $_ctorCase = $this->attrIntValue($_val); + $this->checkAttrCase($_ctorCase); + $this->attrCase = $_ctorCase; + } elseif ($_iattr == 11) { + $this->oracleNulls = $this->attrIntValue($_val); + } elseif ($_iattr == 2) { + // F-CORE-03: ATTR_TIMEOUT is consumed further down from + // $this->attributes (it needs the DSN, then a live connection), but + // its value must be shape-checked at the same point setAttribute() + // checks it — the RAW value is what gets stored below, and every + // later read does a bare `(int)` on it. attrIntValue()'s only job at + // this call site is therefore to raise the TypeError; its normalized + // result is deliberately unused. + $_unusedTimeout = $this->attrIntValue($_val); + } elseif ($_iattr == 1000) { + if (substr($_dsn, 0, 7) === "sqlite:") { + $_openFlags = (int) $_val; + } elseif (substr($_dsn, 0, 6) === "pgsql:") { + $this->disablePrepares = $this->attrBoolValue($_val); + } elseif (substr($_dsn, 0, 6) === "mysql:") { + $_myBufferedQuery = $this->attrBoolValue($_val) ? 1 : 0; + } + } elseif ($_iattr == 1004 && substr($_dsn, 0, 6) === "mysql:") { + $this->emulatePrepares = $this->attrBoolValue($_val); + } elseif ($_iattr == 1001 && substr($_dsn, 0, 6) === "mysql:") { + $_myLocalInfile = $this->attrBoolValue($_val) ? 1 : 0; + } elseif ($_iattr == 1002 && substr($_dsn, 0, 6) === "mysql:") { + $_myInitCommand = (string) $_val; + } elseif ($_iattr == 1003 && substr($_dsn, 0, 6) === "mysql:") { + $_myCompress = $this->attrBoolValue($_val) ? 1 : 0; + } elseif ($_iattr == 1005 && substr($_dsn, 0, 6) === "mysql:") { + // F-MY-06: Pdo\Mysql::ATTR_FOUND_ROWS. The value is 1005, NOT 1013 + // (which is ATTR_MULTI_STATEMENTS): under mysqlnd — PHP's default, and + // the build this prelude's constant block mirrors — php-src's + // php_pdo_mysql_int.h enum omits MAX_BUFFER_SIZE/READ_DEFAULT_FILE/ + // READ_DEFAULT_GROUP, so ATTR_COMPRESS=1003, ATTR_DIRECT_QUERY=1004 and + // ATTR_FOUND_ROWS=1005. Threaded to the bridge's connect path, which + // ORs CLIENT_FOUND_ROWS into the handshake capability flags + // (mysql_driver.c:776-778) so an UPDATE's rowCount() reports the number + // of rows MATCHED rather than the number actually CHANGED — the + // difference between 1 and 0 for an UPDATE writing the value a row + // already holds. No sqlite:/pgsql: constant shares this number, and the + // bridge only consults it for a mysql: DSN, so it is inert elsewhere. + $_myFoundRows = ((bool) $_val) ? 1 : 0; + } elseif ($_iattr == 1006 && substr($_dsn, 0, 6) === "mysql:") { + $_myIgnoreSpace = $this->attrBoolValue($_val) ? 1 : 0; + } elseif ($_iattr == 1009) { + $_mySslCa = (string) $_val; + } elseif ($_iattr == 1008) { + $_mySslCert = (string) $_val; + } elseif ($_iattr == 1007) { + $_mySslKey = (string) $_val; + } elseif ($_iattr == 1014) { + $_mySslVerify = ((bool) $_val) ? 1 : 0; + } elseif ($_iattr == 1010 && substr($_dsn, 0, 6) === "mysql:") { + $_mySslCapath = (string) $_val; + } elseif ($_iattr == 1011 && substr($_dsn, 0, 6) === "mysql:") { + $_mySslCipher = (string) $_val; + } elseif ($_iattr == 1012 && substr($_dsn, 0, 6) === "mysql:") { + $_myServerPublicKey = (string) $_val; + } elseif ($_iattr == 1013 && substr($_dsn, 0, 6) === "mysql:") { + $_myMultiStatements = $this->attrBoolValue($_val) ? 1 : 0; + } elseif ($_iattr == 1015 && substr($_dsn, 0, 6) === "mysql:") { + $_myLocalInfileDirectory = (string) $_val; + } + $this->attributes[$_iattr] = $_val; + } + } + if ($_statementClassConfigured && $this->persistent) { + throw new PDOException("SQLSTATE[HY000]: General error: PDO::ATTR_STATEMENT_CLASS cannot be used with persistent PDO instances"); + } + if (str_starts_with($_dsn, "sqlsrv:") && $this->persistent) { + throw PDOException::__elephcFromErrorInfo( + "SQLSTATE[IMSSP]: An unsupported attribute was designated on the PDO object.", + ["IMSSP", -38, "An unsupported attribute was designated on the PDO object."] + ); + } + if (str_starts_with($_dsn, "sqlsrv:") && isset($this->attributes[0])) { + throw PDOException::__elephcFromErrorInfo( + "SQLSTATE[IMSSP]: An unsupported attribute was designated on the PDO object.", + ["IMSSP", -38, "An unsupported attribute was designated on the PDO object."] + ); + } + // SQLite ignores credentials. For PostgreSQL and MySQL, the user/password may be + // passed as the PDO constructor arguments (PHP-style); fold them into the DSN's + // `key=value` list, where the bridge parses them. + // + // F-CORE-02: php-src's CREDENTIAL PRECEDENCE IS ASYMMETRIC BY DRIVER, and this + // prelude used to apply the pgsql rule to both: + // pgsql (pgsql_driver.c:1377-1378) — the conninfo string is assembled with the + // DSN's own keys AFTER the constructor's user/password, and libpq's conninfo + // parsing is last-wins, so the DSN WINS. (P2-6 already implemented this, and + // it is correct: only a key the DSN does not carry is appended.) + // mysql (mysql_driver.c:948-953) — `if (!dbh->username && vars[5].optval) + // dbh->username = …` (same shape for the password): the DSN key is consulted + // ONLY as a fallback for an absent constructor argument, so the CONSTRUCTOR + // ARGUMENT WINS. `new PDO("mysql:host=h;user=readonly", "admin", $pw)` + // connects as `admin` in real PHP and used to connect as `readonly` here — a + // silent privilege swap in whichever direction the caller did not expect. + // + // MECHANISM (verified by reading the bridge parser, not assumed): a plain APPEND + // is enough to make the constructor argument win for mysql, because + // crates/elephc-pdo/src/my.rs::build_opts walks `body.split(';')` and assigns + // `match key { "user" => user = Some(value), … }` into ONE slot per key — a later + // duplicate simply overwrites the earlier one, i.e. the parser is LAST-WINS. The + // DSN's own `user=`/`password=` therefore does not have to be stripped out. + // + // F-CORE-02 (follow-up): the LAST-WINS mechanism above still relies on the same + // `body.split(';')` the DSN itself is scanned with, so a ';' embedded in the + // constructor username/password would silently truncate the credential right + // there (and a stray '%' would collide with the percent-decoding this note is + // about to describe). Percent-encode '%' and ';' on the credential VALUE before + // appending it — '%' FIRST, so the '%' introduced by encoding ';' is not itself + // re-encoded — and percent-decode ONLY the user/password values on the bridge + // side (my.rs/pg.rs). '=' needs no encoding since the parser splits on the first + // '=' only. This leaves the ';'-splitter itself, and every non-credential value + // (host, dbname with '\' or '%', etc.), byte-identical; a credential with no + // special characters round-trips unchanged too. + if (str_starts_with($_dsn, "pgsql:") || str_starts_with($_dsn, "mysql:") || str_starts_with($_dsn, "dblib:") || str_starts_with($_dsn, "firebird:") || str_starts_with($_dsn, "odbc:") || str_starts_with($_dsn, "informix:") || str_starts_with($_dsn, "ibm:") || str_starts_with($_dsn, "oci:") || str_starts_with($_dsn, "sqlsrv:") || str_starts_with($_dsn, "cubrid:")) { + $_dsnIsMysql = str_starts_with($_dsn, "mysql:"); + $_dsnIsDblib = str_starts_with($_dsn, "dblib:"); + $_dsnIsFirebird = str_starts_with($_dsn, "firebird:"); + $_dsnIsOdbc = str_starts_with($_dsn, "odbc:"); + $_dsnIsInformix = str_starts_with($_dsn, "informix:"); + $_dsnIsIbm = str_starts_with($_dsn, "ibm:"); + $_dsnIsOci = str_starts_with($_dsn, "oci:"); + $_dsnIsSqlsrv = str_starts_with($_dsn, "sqlsrv:"); + $_dsnIsCubrid = str_starts_with($_dsn, "cubrid:"); + if ($username !== null && ($_dsnIsMysql || $_dsnIsDblib || $_dsnIsFirebird || $_dsnIsOci || $_dsnIsCubrid || !str_contains($_dsn, "user="))) { + $_encUser = str_replace(";", "%3B", str_replace("%", "%25", $username)); + $_dsn = $_dsn . ";user=" . $_encUser; + } + if ($password !== null && ($_dsnIsMysql || $_dsnIsDblib || $_dsnIsFirebird || $_dsnIsOci || $_dsnIsCubrid || !str_contains($_dsn, "password="))) { + $_encPass = str_replace(";", "%3B", str_replace("%", "%25", $password)); + $_dsn = $_dsn . ";password=" . $_encPass; + } + // P2-1: ATTR_TIMEOUT maps to the driver's connect-time socket + // timeout. libpq's `connect_timeout` conninfo key and the mysql + // client's `connect_timeout` DSN key (mapped to + // OptsBuilder::tcp_connect_timeout in my.rs) are both plain + // `key=value` pairs their respective parsers already understand, so + // folding this into the DSN needs no further bridge change — only + // applied when the DSN does not already specify it. + if (isset($this->attributes[2])) { + if ($_dsnIsDblib) { + // PDO_DBLIB applies ATTR_TIMEOUT to both login and statement + // timeouts unless either driver-specific option overrides it. + if (!isset($this->attributes[1000]) && !str_contains($_dsn, "connection_timeout=")) { + $_dsn = $_dsn . ";connection_timeout=" . ((int) $this->attributes[2]); + } + if (!isset($this->attributes[1001]) && !str_contains($_dsn, "query_timeout=")) { + $_dsn = $_dsn . ";query_timeout=" . ((int) $this->attributes[2]); + } + } elseif (!str_contains($_dsn, "connect_timeout=")) { + $_dsn = $_dsn . ";connect_timeout=" . ((int) $this->attributes[2]); + } + } + if ($_dsnIsDblib) { + if (isset($this->attributes[1000])) { + $_dsn = $_dsn . ";connection_timeout=" . ((int) $this->attributes[1000]); + } + if (isset($this->attributes[1001])) { + $_dsn = $_dsn . ";query_timeout=" . ((int) $this->attributes[1001]); + } + if (isset($this->attributes[1002])) { + $_dsn = $_dsn . ";stringify_uniqueidentifier=" . ($this->attrBoolValue($this->attributes[1002]) ? "1" : "0"); + } + if (isset($this->attributes[1005])) { + $_dsn = $_dsn . ";skip_empty_rowsets=" . ($this->attrBoolValue($this->attributes[1005]) ? "1" : "0"); + } + if (isset($this->attributes[1006])) { + $_dsn = $_dsn . ";datetime_convert=" . ($this->attrBoolValue($this->attributes[1006]) ? "1" : "0"); + } + } + if ($_dsnIsOdbc) { + $_odbcCursorLibrary = isset($this->attributes[1000]) ? $this->attrIntValue($this->attributes[1000]) : 0; + $_odbcAssumeUtf8 = isset($this->attributes[1001]) && $this->attrBoolValue($this->attributes[1001]); + $_dsn = $_dsn . ";elephc_odbc_cursor_library=" . $_odbcCursorLibrary + . ";elephc_odbc_assume_utf8=" . ($_odbcAssumeUtf8 ? "1" : "0") + . ";elephc_odbc_autocommit=" . ($this->autoCommit ? "1" : "0"); + } elseif ($_dsnIsInformix || $_dsnIsIbm) { + $_dsn = $_dsn . ";elephc_odbc_autocommit=" . ($this->autoCommit ? "1" : "0"); + if ($_dsnIsIbm && $options !== null) { + foreach ($options as $_ibmKey => $_ibmRawValue) { + if (!is_int($_ibmKey)) { + continue; + } + $_ibmAttribute = (int) $_ibmKey; + if ($_ibmAttribute == 2561) { + if ($this->attrBoolValue($_ibmRawValue)) { + $_dsn = $_dsn . ";elephc_ibm_attr_2561=1"; + // PDO_IBM 1.7.0 breaks its driver-options loop here. + break; + } + } elseif ($_ibmAttribute == 1281 || $_ibmAttribute == 1282 || $_ibmAttribute == 1283 || $_ibmAttribute == 1284 || $_ibmAttribute == 2562 || $_ibmAttribute == 2563) { + $_ibmValue = (string) $_ibmRawValue; + $_ibmValue = str_replace(";", "%3B", str_replace("%", "%25", $_ibmValue)); + $_dsn = $_dsn . ";elephc_ibm_attr_" . $_ibmAttribute . "=" . $_ibmValue; + } + } + } + } elseif ($_dsnIsOci) { + $_dsn = $_dsn . ";elephc_oci_autocommit=" . ($this->autoCommit ? "1" : "0"); + } + } + // Serialize the collected Pdo\Mysql::ATTR_SSL_* options into the packed + // string the bridge parses (only the keys that were actually set are + // emitted; an all-unset config stays "" = no TLS). File paths do not + // contain ';'/'=' in practice, matching the rest of the bridge's DSN-style + // parsing. + $_mySslConfig = ""; + if ($_mySslCa !== "") { + $_mySslConfig = $_mySslConfig . "ca=" . $_mySslCa . ";"; + } + if ($_mySslCert !== "") { + $_mySslConfig = $_mySslConfig . "cert=" . $_mySslCert . ";"; + } + if ($_mySslKey !== "") { + $_mySslConfig = $_mySslConfig . "key=" . $_mySslKey . ";"; + } + if ($_mySslVerify != -1) { + $_mySslConfig = $_mySslConfig . "verify=" . $_mySslVerify . ";"; + } + $_myEncode = function(string $_option): string { + return str_replace("=", "%3D", str_replace(";", "%3B", str_replace("%", "%25", $_option))); + }; + $_myDriverConfig = "local=" . $_myLocalInfile + . ";dir=" . $_myEncode($_myLocalInfileDirectory) + . ";compress=" . $_myCompress + . ";ignore=" . $_myIgnoreSpace + . ";multi=" . $_myMultiStatements + . ";buffered=" . $_myBufferedQuery + . ";capath=" . $_myEncode($_mySslCapath) + . ";cipher=" . $_myEncode($_mySslCipher) + . ";serverkey=" . $_myEncode($_myServerPublicKey) . ";"; + $this->conn = elephc_pdo_open_persistent($_dsn, $this->persistent ? 1 : 0, $_openFlags, $_myInitCommand, $_mySslConfig, $_myFoundRows, $_persistentKey, $_myDriverConfig); + if ($this->conn < 0) { + $_openMsg = elephc_pdo_last_open_error(); + // P1-4: when a real driver recognized the DSN but the connection + // itself failed (bad path / unreachable host / auth failure), PHP + // prefixes the message "SQLSTATE[]: ..." and populates a + // 3-element errorInfo so the standard try/catch-around-`new PDO` + // classification idiom (`$e->errorInfo[0]`) works. There is no live + // connection yet to ask for a native SQLSTATE, so fall back to the + // same class real PHP drivers default to for a connect-time failure: + // "08006" (SQLSTATE connection-exception) for the network-facing + // pgsql/mysql drivers, "HY000" (generic error — pdo_sqlite's own + // default) otherwise; native code is unknown here (null). + // + // F-CORE-13: an UNRECOGNIZED DSN can no longer reach this point at all — + // checkDsnIsSupported(), at the top of this constructor, already rejected it + // with php-src's bare "could not find driver" (no SQLSTATE prefix, errorInfo + // left null) before the bridge was ever called. So every failure here is a + // genuine connect failure of a known driver and always carries a SQLSTATE; + // the old prefix re-test and its bare-message fallback are gone with it. + $_sqlstate = elephc_pdo_last_open_sqlstate(); + $_nativeCode = elephc_pdo_last_open_native_code(); + if ($_sqlstate === "") { + $_sqlstate = (str_starts_with($_dsn, "sqlite:") || str_starts_with($_dsn, "oci:")) ? "HY000" : "08006"; + } + $_nativeInfo = $_nativeCode == 0 ? null : $_nativeCode; + throw PDOException::__elephcFromErrorInfo("SQLSTATE[" . $_sqlstate . "]: " . $_openMsg, [$_sqlstate, $_nativeInfo, $_openMsg]); + } + // Reset pooled MySQL sessions as well as new ones: a prior persistent + // borrower may have disabled autocommit. Other drivers reject this + // attribute through their own hooks and are deliberately untouched. + if (str_starts_with($_dsn, "mysql:")) { + if (elephc_pdo_set_autocommit($this->conn, $this->autoCommit ? 1 : 0) !== 1) { + $this->fail(elephc_pdo_errmsg($this->conn)); + } + if (isset($this->attributes[14])) { + elephc_pdo_set_fetch_table_names($this->conn, $this->attrBoolValue($this->attributes[14]) ? 1 : 0); + } + } elseif (str_starts_with($_dsn, "sqlite:")) { + if (isset($this->attributes[1002])) { + elephc_pdo_set_extended_result_codes($this->conn, $this->attrBoolValue($this->attributes[1002]) ? 1 : 0); + } + if (isset($this->attributes[1005])) { + $_constructorTransactionMode = $this->attrIntValue($this->attributes[1005]); + if ($_constructorTransactionMode >= 0 && $_constructorTransactionMode <= 2) { + elephc_pdo_set_transaction_mode($this->conn, $_constructorTransactionMode); + } + } + } elseif (str_starts_with($_dsn, "pgsql:") && isset($this->attributes[1])) { + elephc_pdo_set_prefetch($this->conn, $this->attrBoolValue($this->attributes[1]) ? 1 : 0); + } elseif (str_starts_with($_dsn, "firebird:")) { + foreach ([0, 14, 1003, 1007] as $_firebirdIntAttribute) { + if (isset($this->attributes[$_firebirdIntAttribute])) { + $_firebirdValue = ($_firebirdIntAttribute == 0 || $_firebirdIntAttribute == 14 || $_firebirdIntAttribute == 1007) + ? ($this->attrBoolValue($this->attributes[$_firebirdIntAttribute]) ? 1 : 0) + : $this->attrIntValue($this->attributes[$_firebirdIntAttribute]); + elephc_pdo_firebird_set_attribute_int($this->conn, $_firebirdIntAttribute, $_firebirdValue); + } + } + foreach ([1000, 1001, 1002] as $_firebirdTextAttribute) { + if (isset($this->attributes[$_firebirdTextAttribute])) { + elephc_pdo_firebird_set_attribute_text($this->conn, $_firebirdTextAttribute, (string) $this->attributes[$_firebirdTextAttribute]); + } + } + } elseif (str_starts_with($_dsn, "sqlsrv:")) { + foreach ([10, 1003, 1009] as $_sqlsrvStatementOnlyAttribute) { + if (isset($this->attributes[$_sqlsrvStatementOnlyAttribute])) { + throw PDOException::__elephcFromErrorInfo( + "SQLSTATE[IMSSP]: The given attribute is only supported on the PDOStatement object.", + ["IMSSP", -39, "The given attribute is only supported on the PDOStatement object."] + ); + } + } + foreach ([17, 20, 21, 1000, 1001, 1002, 1004, 1005, 1006, 1007, 1008] as $_sqlsrvAttribute) { + if (!isset($this->attributes[$_sqlsrvAttribute])) { + continue; + } + $_sqlsrvRaw = $this->attributes[$_sqlsrvAttribute]; + $_sqlsrvValue = ($_sqlsrvAttribute == 17 + || $_sqlsrvAttribute == 20 + || $_sqlsrvAttribute == 1002 + || $_sqlsrvAttribute == 1005 + || $_sqlsrvAttribute == 1006 + || $_sqlsrvAttribute == 1007) + ? ($this->attrBoolValue($_sqlsrvRaw) ? 1 : 0) + : $this->attrIntValue($_sqlsrvRaw); + if (elephc_pdo_odbc_set_attribute($this->conn, $_sqlsrvAttribute, $_sqlsrvValue) !== 1) { + throw PDOException::__elephcFromErrorInfo( + "SQLSTATE[IMSSP]: An invalid attribute was designated on the PDO object.", + ["IMSSP", 0, "An invalid attribute was designated on the PDO object."] + ); + } + } + } elseif (str_starts_with($_dsn, "oci:")) { + foreach ([0, 1, 1004] as $_ociIntAttribute) { + if (isset($this->attributes[$_ociIntAttribute])) { + $_ociValue = $_ociIntAttribute == 0 + ? ($this->attrBoolValue($this->attributes[$_ociIntAttribute]) ? 1 : 0) + : $this->attrIntValue($this->attributes[$_ociIntAttribute]); + elephc_pdo_oci_set_attribute_int($this->conn, $_ociIntAttribute, $_ociValue); + } + } + foreach ([1000, 1001, 1002, 1003] as $_ociTextAttribute) { + if (isset($this->attributes[$_ociTextAttribute])) { + elephc_pdo_oci_set_attribute_text($this->conn, $_ociTextAttribute, (string) $this->attributes[$_ociTextAttribute]); + } + } + } elseif (str_starts_with($_dsn, "cubrid:")) { + if (isset($this->attributes[0])) { + elephc_pdo_set_autocommit($this->conn, $this->autoCommit ? 1 : 0); + } + foreach ([1000, 1001] as $_cubridAttribute) { + if (isset($this->attributes[$_cubridAttribute])) { + elephc_pdo_cubrid_set_attribute($this->conn, $_cubridAttribute, $this->attrIntValue($this->attributes[$_cubridAttribute])); + } + } + } + // ATTR_TIMEOUT needs a live connection, so apply it after the open (the + // pre-open loop only records it). PHP's value is in seconds; SQLite's + // busy-timeout is milliseconds. For PostgreSQL/MySQL this is now a + // harmless no-op layered on top of the connect_timeout DSN key above, + // which is what actually bounds the connect-time wait (P2-1). + if (isset($this->attributes[2])) { + if (str_starts_with($_dsn, "cubrid:")) { + elephc_pdo_cubrid_set_attribute($this->conn, 2, (int) $this->attributes[2]); + } elseif (str_starts_with($_dsn, "dblib:")) { + elephc_pdo_dblib_set_attribute($this->conn, 2, (int) $this->attributes[2]); + } else { + elephc_pdo_set_busy_timeout($this->conn, ((int) $this->attributes[2]) * 1000); + } + } + } + + private function dblibErrorInfo(string $message): array { + $_sqlstate = elephc_pdo_sqlstate($this->conn); + $_native = elephc_pdo_errcode($this->conn); + $_osCode = elephc_pdo_dblib_os_errcode($this->conn); + $_severity = elephc_pdo_dblib_severity($this->conn); + $_formatted = $message . " [" . $_native . "] (severity " . $_severity . ") []"; + $_info = [$_sqlstate, $_native, $_formatted, $_osCode, $_severity]; + $_osMessage = elephc_pdo_dblib_os_errmsg($this->conn); + if ($_osMessage !== "") { + $_info[] = $_osMessage; + } + return $_info; + } + + private function fail(string $message): void { + // Apply the current error mode to a failed operation. EXCEPTION throws; + // WARNING writes to stderr and lets the caller return its failure value; + // SILENT is quiet and the caller returns its failure value. The SQLSTATE + // and native driver code are attached so callers can read $e->errorInfo + // (frameworks parse errorInfo[0] as the SQLSTATE). + if ($this->errMode == 0) { + return; + } + $_sqlstate = elephc_pdo_sqlstate($this->conn); + $_native = elephc_pdo_errcode($this->conn); + // php-src pdo_handle_error builds "SQLSTATE[%s]: %s: %d %s" (state, + // description, native code, driver message); errorInfo keeps the raw + // [state, native, message] triple frameworks read via $e->errorInfo. + $_errorInfo = [$_sqlstate, $_native, $message]; + if (elephc_pdo_driver_name($this->conn) === "dblib") { + $_errorInfo = $this->dblibErrorInfo($message); + $message = (string) $_errorInfo[2]; + } + $_full = "SQLSTATE[" . $_sqlstate . "]: " . __elephc_pdo_sqlstate_description($_sqlstate) . ": " . $_native . " " . $message; + if ($this->errMode == 2) { + throw PDOException::__elephcFromErrorInfo($_full, $_errorInfo); + } + fwrite(STDERR, "PDO error: " . $_full . "\n"); + } + + // PHP exceptions cannot unwind through SQLite's C authorizer frame. The bridge + // therefore records invalid callback results and this outer PDO boundary raises + // the same Error subclass/message once SQLite has safely returned. + private function throwAuthorizerError(string $operation): void { + $_authorizerError = elephc_pdo_take_authorizer_error($this->conn); + if ($_authorizerError == 0) { + return; + } + if ($_authorizerError == 1) { + throw new ValueError($operation . "(): Return value of the authorizer callback must be one of Pdo\\Sqlite::OK, Pdo\\Sqlite::DENY, or Pdo\\Sqlite::IGNORE"); + } + if ($_authorizerError == 2) { + throw new Error($operation . "(): SQLite authorizer callback raised an exception"); + } + $_returnedType = "object"; + if ($_authorizerError == 10) { + $_returnedType = "null"; + } elseif ($_authorizerError == 11) { + $_returnedType = "float"; + } elseif ($_authorizerError == 12) { + $_returnedType = "string"; + } elseif ($_authorizerError == 13) { + $_returnedType = "bool"; + } elseif ($_authorizerError == 14) { + $_returnedType = "array"; + } + throw new TypeError($operation . "(): Return value of the authorizer callback must be of type int, " . $_returnedType . " returned"); + } + + // F-CORE-04/F-CORE-05: a SYNTHETIC (non-driver) connection-level error, mirroring + // php-src's `pdo_raise_impl_error` — it writes a caller-given SQLSTATE instead of + // reading the driver's live error state, because there was no failed query to read + // one from. Fully errMode-aware, exactly like fail() above: EXCEPTION throws, + // WARNING writes to stderr, SILENT is quiet — and in every mode the caller goes on + // to return its own failure value. PDOStatement has carried the identical helper + // since P1-i; \PDO was missing it, which is precisely why getAttribute() used to + // answer a nonsense attribute number with a bare null instead of raising IM001. + // (setAttribute() deliberately does NOT use it — see its own F-CORE-04 comment: real + // PHP rejects an unknown attribute there SILENTLY.) + private function failCode(string $sqlstate, string $message): void { + if ($this->errMode == 0) { + return; + } + $_full = __elephc_pdo_impl_error_message($sqlstate, $message); + if ($this->errMode == 2) { + throw PDOException::__elephcFromErrorInfo($_full, [$sqlstate, 0]); + } + fwrite(STDERR, "PDO error: " . $_full . "\n"); + } + + // P1-h: ATTR_ERRMODE (3) only accepts PDO::ERRMODE_SILENT/WARNING/EXCEPTION + // (0/1/2); anything else throws a ValueError and leaves the current mode + // untouched — shared by setAttribute() and the constructor's $options loop. + private function checkErrMode(int $mode): void { + if ($mode != 0 && $mode != 1 && $mode != 2) { + throw new ValueError("Error mode must be one of the PDO::ERRMODE_* constants"); + } + } + + // P1-h/P3: ATTR_DEFAULT_FETCH_MODE (19) rejects only PDO::FETCH_USE_DEFAULT + // (0, i.e. "no mode") — shared by setAttribute() and the constructor's + // $options loop. Divergence check against php-src's pdo_dbh.c (verified): + // real PHP's FETCH_CLASS/FETCH_INTO rejection ("PDO::FETCH_INTO and + // PDO::FETCH_CLASS cannot be set as the default fetch mode") ONLY fires + // when the given value is an ARRAY whose element [0] is one of those modes + // (the `setAttribute(ATTR_DEFAULT_FETCH_MODE, [PDO::FETCH_CLASS, 'Foo'])` + // idiom); a BARE int 8/9 is accepted and stored like any other mode. Since + // elephc's setAttribute() takes a plain `mixed $value` and this prelude + // only ever narrows it with `(int) $value`, the array-form never reaches + // here at all, so there is no elephc analogue of that rejection to mirror. + private function checkDefaultFetchMode(int $mode): void { + if ($mode == 0) { + throw new ValueError("Fetch mode must be a bitmask of PDO::FETCH_* constants"); + } + } + + // P2-e: ATTR_CASE (8) only accepts PDO::CASE_NATURAL/CASE_UPPER/CASE_LOWER + // (0/1/2); anything else throws a ValueError with the exact message php-src's + // pdo_dbh.c uses (verified against php-src) — shared by setAttribute() and the + // constructor's $options loop. Divergence: PDO::ATTR_ORACLE_NULLS (11) has NO + // equivalent check in real PHP either (pdo_dbh.c carries a + // `/* TODO Check for valid value */` comment and stores whatever integer is + // given), so there is no analogous helper for it here; PDOStatement's fetch + // path only pattern-matches NULL_EMPTY_STRING(1)/NULL_TO_STRING(2) and treats + // every other stored value as a no-op natural mode, mirroring that unchecked + // acceptance exactly. + private function checkAttrCase(int $mode): void { + if ($mode != 0 && $mode != 1 && $mode != 2) { + throw new ValueError("Case folding mode must be one of the PDO::CASE_* constants"); + } + } + + // F-CORE-03: php-src names the offending value with zend_zval_value_name() in + // the TypeError the two helpers below raise; mirror the spellings it produces + // for every shape a PHP-level attribute value can actually reach here. + private function attrValueTypeName(mixed $value): string { + if (is_int($value)) { + return "int"; + } + if (is_bool($value)) { + return "bool"; + } + if (is_float($value)) { + return "float"; + } + if (is_string($value)) { + return "string"; + } + if (is_array($value)) { + return "array"; + } + if (is_null($value)) { + return "null"; + } + return "object"; + } + + // F-CORE-03 (SECURITY-adjacent): php-src checks the SHAPE of an attribute + // value BEFORE any per-attribute range check — pdo_get_long_param() accepts + // only IS_LONG, IS_TRUE/IS_FALSE, or a string that is_numeric_str_function() + // reports as IS_LONG, and raises a TypeError otherwise. This prelude used to + // cast blindly with `(int) $value`, and `(int) "banana"` is 0 — which is + // PDO::ERRMODE_SILENT, a value checkErrMode() happily accepts — so + // `setAttribute(PDO::ATTR_ERRMODE, "banana")` silently switched the connection + // to SILENT and swallowed every subsequent error. Shared by setAttribute() and + // the constructor's $options loop, which had the identical blind-cast problem. + private function attrIntValue(mixed $value): int { + if (is_int($value) || is_bool($value)) { + return (int) $value; + } + if (is_string($value)) { + $_sval = (string) $value; + // php-src takes a string only when it parses as IS_LONG, so an + // INTEGER-shaped numeric string passes while a float-shaped one + // ("1.5", "1e3" — both IS_DOUBLE) falls through to the TypeError; + // is_numeric() alone would wrongly accept those, hence the explicit + // fractional/exponent rejection. + if (is_numeric($_sval) && strpos($_sval, ".") === false && strpos($_sval, "e") === false && strpos($_sval, "E") === false) { + return (int) $_sval; + } + } + throw new TypeError("Attribute value must be of type int for selected attribute, " . $this->attrValueTypeName($value) . " given"); + } + + // F-CORE-03: the bool-typed counterpart, mirroring pdo_get_bool_param() — + // only IS_TRUE/IS_FALSE/IS_LONG are accepted there (its `case IS_STRING:` + // deliberately falls through to the TypeError, so a string is NOT a valid + // bool attribute value even when it looks like one). + private function attrBoolValue(mixed $value): bool { + if (is_bool($value) || is_int($value)) { + return (bool) $value; + } + throw new TypeError("Attribute value must be of type bool for selected attribute, " . $this->attrValueTypeName($value) . " given"); + } + + // Validates both the connection-level ATTR_STATEMENT_CLASS value and a prepare()-local + // override. The AOT helper returns enough metadata to mirror php-src's distinct errors + // without exposing compiler class tables to PHP code. Abstract subclasses are accepted + // here and rejected only by prepare(), matching object_init_ex() timing in php-src. + private function validateStatementClassConfig(mixed $value, bool $fromSetAttribute): array { + if (!is_array($value)) { + if ($fromSetAttribute) { + throw new TypeError("PDO::setAttribute(): Argument #2 (\$value) PDO::ATTR_STATEMENT_CLASS value must be of type array, " . $this->attrValueTypeName($value) . " given"); + } + throw new TypeError("PDO::ATTR_STATEMENT_CLASS value must be of type array, " . $this->attrValueTypeName($value) . " given"); + } + if (!array_key_exists(0, $value)) { + if ($fromSetAttribute) { + throw new ValueError("PDO::setAttribute(): Argument #2 (\$value) PDO::ATTR_STATEMENT_CLASS value must be an array with the format array(classname, constructor_args)"); + } + throw new ValueError("PDO::ATTR_STATEMENT_CLASS value must be an array with the format array(classname, constructor_args)"); + } + if (!is_string($value[0])) { + if ($fromSetAttribute) { + throw new TypeError("PDO::setAttribute(): Argument #2 (\$value) PDO::ATTR_STATEMENT_CLASS class must be a valid class"); + } + throw new TypeError("PDO::ATTR_STATEMENT_CLASS class must be a valid class"); + } + $_class = (string) $value[0]; + $_status = __elephc_pdo_statement_class_status($_class); + if ($_status == 0) { + if ($fromSetAttribute) { + throw new TypeError("PDO::setAttribute(): Argument #2 (\$value) PDO::ATTR_STATEMENT_CLASS class must be a valid class"); + } + throw new TypeError("PDO::ATTR_STATEMENT_CLASS class must be a valid class"); + } + if ($_status == 1) { + if ($fromSetAttribute) { + throw new TypeError("PDO::setAttribute(): Argument #2 (\$value) PDO::ATTR_STATEMENT_CLASS class must be derived from PDOStatement"); + } + throw new TypeError("PDO::ATTR_STATEMENT_CLASS class must be derived from PDOStatement"); + } + if ($_status == 2) { + if ($fromSetAttribute) { + throw new TypeError("PDO::setAttribute(): Argument #2 (\$value) User-supplied statement class cannot have a public constructor"); + } + throw new TypeError("User-supplied statement class cannot have a public constructor"); + } + $_config = [$_class]; + if (array_key_exists(1, $value)) { + if (!is_array($value[1])) { + // php-src 8.0-8.6 accidentally names the outer attribute value here, + // so the reported type is "array" even though index 1 is the offender. + if ($fromSetAttribute) { + throw new TypeError("PDO::setAttribute(): Argument #2 (\$value) PDO::ATTR_STATEMENT_CLASS constructor_args must be of type ?array, array given"); + } + throw new TypeError("PDO::ATTR_STATEMENT_CLASS constructor_args must be of type ?array, array given"); + } + $_config[1] = $value[1]; + } + return $_config; + } + + public function setAttribute(int $attribute, $value): bool { + $_driver = elephc_pdo_driver_name($this->conn); + if ($attribute == 0 && ($_driver === "mysql" || $_driver === "odbc" || $_driver === "informix" || $_driver === "ibm" || $_driver === "oci" || $_driver === "cubrid")) { + $_autocommit = $this->attrBoolValue($value); + if (elephc_pdo_set_autocommit($this->conn, $_autocommit ? 1 : 0) !== 1) { + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + $this->autoCommit = $_autocommit; + } elseif ($attribute == 0 && $_driver === "firebird") { + $_autocommit = $this->attrBoolValue($value); + if (elephc_pdo_firebird_set_attribute_int($this->conn, 0, $_autocommit ? 1 : 0) !== 1) { + return false; + } + $this->autoCommit = $_autocommit; + } elseif ($attribute == 3) { + // F-CORE-03: the shape check runs BEFORE the range check, exactly as + // php-src's pdo_get_long_param() does — see attrIntValue() for why a + // blind cast here was actively dangerous for ATTR_ERRMODE. + $_attrErrMode = $this->attrIntValue($value); + $this->checkErrMode($_attrErrMode); + $this->errMode = $_attrErrMode; + } elseif ($attribute == 13) { + if ($this->persistent) { + $this->failCode("HY000", "PDO::ATTR_STATEMENT_CLASS cannot be used with persistent PDO instances"); + return false; + } + $this->statementClassConfig = $this->validateStatementClassConfig($value, true); + } elseif ($attribute == 2 && $_driver === "sqlite") { + // ATTR_TIMEOUT: SQLite maps it to a busy-timeout; PHP's unit is + // seconds, SQLite's is milliseconds. Other drivers accept it as a + // no-op (see the bridge). + elephc_pdo_set_busy_timeout($this->conn, $this->attrIntValue($value) * 1000); + } elseif ($attribute == 2 && $_driver === "dblib") { + return elephc_pdo_dblib_set_attribute($this->conn, 2, $this->attrIntValue($value)) === 1; + } elseif ($attribute == 2 && $_driver === "cubrid") { + return elephc_pdo_cubrid_set_attribute($this->conn, 2, $this->attrIntValue($value)) === 1; + } elseif ($attribute == 19) { + $_attrFetchMode = $this->attrIntValue($value); + $this->checkDefaultFetchMode($_attrFetchMode); + $this->defaultFetchMode = $_attrFetchMode; + } elseif ($attribute == 17) { + $this->stringifyFetches = $this->attrBoolValue($value); + } elseif ($attribute == 21 && ($_driver === "mysql" || $_driver === "dblib" || $_driver === "sqlsrv")) { + $_defaultStringType = $this->attrIntValue($value); + $this->defaultStrParam = ($_defaultStringType == 0x40000000) ? 0x40000000 : 0x20000000; + if ($_driver === "sqlsrv") { + return elephc_pdo_odbc_set_attribute($this->conn, 21, $this->defaultStrParam) === 1; + } + } elseif ($attribute == 14 && $_driver === "mysql") { + return elephc_pdo_set_fetch_table_names($this->conn, $this->attrBoolValue($value) ? 1 : 0) === 1; + } elseif ($attribute == 1000 && $_driver === "mysql") { + return elephc_pdo_set_buffered_query($this->conn, $this->attrBoolValue($value) ? 1 : 0) === 1; + } elseif ($attribute == 1 && $_driver === "pgsql") { + return elephc_pdo_set_prefetch($this->conn, $this->attrBoolValue($value) ? 1 : 0) === 1; + } elseif ($attribute == 1 && $_driver === "oci") { + return elephc_pdo_oci_set_attribute_int($this->conn, 1, $this->attrIntValue($value)) === 1; + } elseif ($attribute == 20) { + if ($_driver === "dblib") { + // php-src exposes emulation as read-only true: DB-Library has no + // native prepare API and its set_attribute hook rejects this key. + return false; + } + if ($_driver === "sqlsrv") { + $this->emulatePrepares = $this->attrBoolValue($value); + return elephc_pdo_odbc_set_attribute($this->conn, 20, $this->emulatePrepares ? 1 : 0) === 1; + } + if ($_driver !== "mysql" && $_driver !== "pgsql") { + return false; + } + $this->emulatePrepares = $this->attrBoolValue($value); + } elseif ($attribute == 8) { + $_attrCase = $this->attrIntValue($value); + $this->checkAttrCase($_attrCase); + $this->attrCase = $_attrCase; + } elseif ($attribute == 11) { + $this->oracleNulls = $this->attrIntValue($value); + } elseif ($attribute == 1002 && $_driver === "sqlite") { + // F-SQLT-02: Pdo\Sqlite::ATTR_EXTENDED_RESULT_CODES. php-src's + // pdo_sqlite_set_attribute calls sqlite3_extended_result_codes(), which + // widens the driver-specific code in errorInfo[1] from the coarse primary + // code (SQLITE_CONSTRAINT, 19) to the extended one that says WHICH + // constraint failed (SQLITE_CONSTRAINT_UNIQUE, 2067) — the difference + // between "a constraint broke" and an actionable error. + // + // The driver guard is required, not defensive noise: 1002 is a colliding + // number. It is Pdo\Mysql::ATTR_INIT_COMMAND (a STRING, consumed at + // connect time by the constructor's $options loop) on a mysql: connection, + // so an unguarded branch would push that string through attrBoolValue() + // and raise a spurious TypeError. Each driver owns its own 1000+ range; + // this attribute only means "extended result codes" for sqlite:. + elephc_pdo_set_extended_result_codes($this->conn, $this->attrBoolValue($value) ? 1 : 0); + } elseif ($attribute == 1005 && $_driver === "sqlite") { + // PHP 8.5 Pdo\Sqlite::ATTR_TRANSACTION_MODE. php-src accepts the ordinary + // PDO integer coercions, but returns false without changing state outside 0..2. + $_transactionMode = $this->attrIntValue($value); + if ($_transactionMode < 0 || $_transactionMode > 2) { + return false; + } + return elephc_pdo_set_transaction_mode($this->conn, $_transactionMode) === 1; + } elseif ($attribute == 1000 && $_driver === "pgsql") { + $this->disablePrepares = $this->attrBoolValue($value); + } elseif ($attribute == 1004 && $_driver === "mysql") { + $this->emulatePrepares = $this->attrBoolValue($value); + } elseif (($attribute == 1000 || $attribute == 1001) && $_driver === "cubrid") { + return elephc_pdo_cubrid_set_attribute($this->conn, $attribute, $this->attrIntValue($value)) === 1; + } elseif (($attribute == 1001 || $attribute == 1002 || $attribute == 1005 || $attribute == 1006) && $_driver === "dblib") { + if ($attribute == 1001) { + return elephc_pdo_dblib_set_attribute($this->conn, $attribute, $this->attrIntValue($value)) === 1; + } + return elephc_pdo_dblib_set_attribute($this->conn, $attribute, $this->attrBoolValue($value) ? 1 : 0) === 1; + } elseif (($attribute == 1000 || $attribute == 1001 || $attribute == 1002) && $_driver === "firebird") { + return elephc_pdo_firebird_set_attribute_text($this->conn, $attribute, (string) $value) === 1; + } elseif ((($attribute == 14 || $attribute == 1003 || $attribute == 1007) && $_driver === "firebird") || ($attribute == 1001 && $_driver === "odbc")) { + if ($_driver === "odbc") { + return elephc_pdo_odbc_set_attribute($this->conn, 1001, $this->attrBoolValue($value) ? 1 : 0) === 1; + } + $_firebirdValue = ($attribute == 14 || $attribute == 1007) + ? ($this->attrBoolValue($value) ? 1 : 0) + : $this->attrIntValue($value); + return elephc_pdo_firebird_set_attribute_int($this->conn, $attribute, $_firebirdValue) === 1; + } elseif (($attribute == 1000 || $attribute == 1001 || $attribute == 1002 || $attribute == 1003) && $_driver === "oci") { + return elephc_pdo_oci_set_attribute_text($this->conn, $attribute, (string) $value) === 1; + } elseif ($attribute == 1004 && $_driver === "oci") { + return elephc_pdo_oci_set_attribute_int($this->conn, 1004, $this->attrIntValue($value)) === 1; + } elseif (($attribute == 1281 || $attribute == 1282 || $attribute == 1283 || $attribute == 1284 || $attribute == 2562 || $attribute == 2563) && $_driver === "ibm") { + if (elephc_pdo_ibm_set_attribute_text($this->conn, $attribute, (string) $value) !== 1) { + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + return true; + } elseif ($_driver === "sqlsrv" && ($attribute == 1000 || $attribute == 1001 || $attribute == 1002 || $attribute == 1004 || $attribute == 1005 || $attribute == 1006 || $attribute == 1007 || $attribute == 1008)) { + $_sqlsrvValue = ($attribute == 1002 || $attribute == 1005 || $attribute == 1006 || $attribute == 1007) + ? ($this->attrBoolValue($value) ? 1 : 0) + : $this->attrIntValue($value); + if (elephc_pdo_odbc_set_attribute($this->conn, $attribute, $_sqlsrvValue) !== 1) { + $this->failCode("IMSSP", "An invalid attribute was designated on the PDO object."); + return false; + } + return true; + } elseif ($_driver === "sqlsrv" && ($attribute == 10 || $attribute == 1003 || $attribute == 1009)) { + $this->failCode("IMSSP", "The given attribute is only supported on the PDOStatement object."); + return false; + } elseif ($_driver === "sqlsrv" && ($attribute == 0 || $attribute == 1 || $attribute == 2 || $attribute == 9 || $attribute == 12)) { + $this->failCode("IMSSP", "An unsupported attribute was designated on the PDO object."); + return false; + } elseif ($_driver === "sqlsrv" && ($attribute == 4 || $attribute == 5 || $attribute == 6 || $attribute == 7 || $attribute == 16)) { + $this->failCode("IMSSP", "A read-only attribute was designated on the PDO object."); + return false; + } else { + // F-CORE-04 (CORRECTED — the finalization spec was WRONG about this, and an + // earlier pass implemented the spec's version): an UNKNOWN attribute number + // makes real PHP's setAttribute() return **false SILENTLY**. It raises + // nothing — no exception, no error state — not even under + // ERRMODE_EXCEPTION. VERIFIED against a real PHP 8.5.6 CLI: + // `$pdo->setAttribute(9999, 1)` on an ERRMODE_EXCEPTION handle returns + // bool(false) and `$pdo->errorCode()` still reads "00000". + // + // WHY, in php-src's own terms: pdo_dbh_attribute_set() only reaches + // `pdo_raise_impl_error(…, "IM001", "driver does not support setting + // attributes")` on the `!dbh->methods->set_attribute` arm — a driver with NO + // set_attribute hook AT ALL. All three drivers this bridge implements + // (pdo_sqlite, pdo_mysql, pdo_pgsql) HAVE one, and each simply `return 0`s + // for an attribute it does not recognize WITHOUT setting an error, so the + // PDO_HANDLE_DBH_ERR() that follows finds SQLSTATE "00000" and raises + // nothing. The IM001 arm is therefore unreachable for every driver here. + // + // getAttribute() is GENUINELY ASYMMETRIC and its IM001 (further down) stays: + // pdo_sqlite's get_attribute hook returning 0 lands on an EXPLICIT + // pdo_raise_impl_error, so `getAttribute(9999)` really does throw on a real + // CLI. The asymmetry looks like a bug in php-src; it is nonetheless the + // behavior, and mirroring it is the whole point of this surface. + // + // What DOES survive from the original finding: NOTHING is stored. The old + // code's store-and-return-TRUE was wrong under any reading — a rejected + // attribute must not read back out of getAttribute() — so the reject + // active driver's hook governs support; numeric-range membership alone + // never makes an attribute readable or writable. + $this->hasOperation = true; + return false; + } + return true; + } + + // Virtual compiler-internal hook. Pdo\Pgsql overrides it with the real drain; + // PDOStatement calls through its PDO-typed owner and runtime dispatch reaches + // that override only for PostgreSQL connections. + protected function __elephcDrainPgsqlNotices(): void {} + + public function getAttribute(int $attribute): mixed { + if (elephc_pdo_driver_name($this->conn) === "sqlsrv") { + if ($attribute == 5) { + return [ + "DriverName" => elephc_pdo_sqlsrv_info($this->conn, 3), + "DriverODBCVer" => elephc_pdo_sqlsrv_info($this->conn, 4), + "DriverVer" => elephc_pdo_sqlsrv_info($this->conn, 5), + "ExtensionVer" => "5.13.1", + ]; + } + if ($attribute == 6) { + return [ + "CurrentDatabase" => elephc_pdo_sqlsrv_info($this->conn, 0), + "SQLServerVersion" => elephc_pdo_sqlsrv_info($this->conn, 1), + "SQLServerName" => elephc_pdo_sqlsrv_info($this->conn, 2), + ]; + } + if ($attribute == 1000 || $attribute == 1001 || $attribute == 1002 || $attribute == 1004 || $attribute == 1005 || $attribute == 1006 || $attribute == 1007 || $attribute == 1008) { + $_sqlsrvValue = elephc_pdo_odbc_attribute($this->conn, $attribute); + if ($_sqlsrvValue >= 0) { + return ($attribute == 1002 || $attribute == 1005 || $attribute == 1006 || $attribute == 1007) + ? ($_sqlsrvValue === 1) + : $_sqlsrvValue; + } + } + if ($attribute == 1003 || $attribute == 1009 || $attribute == 10) { + $this->failCode("IMSSP", "The given attribute is only supported on the PDOStatement object."); + return false; + } + if ($attribute == 7) { + $this->failCode("IMSSP", "An invalid attribute was designated on the PDO object."); + return false; + } + } + if (($attribute == 1281 || $attribute == 1282 || $attribute == 1283 || $attribute == 1284 || $attribute == 2562) && elephc_pdo_driver_name($this->conn) === "ibm") { + $_ibmText = elephc_pdo_ibm_attribute_text($this->conn, $attribute); + if (elephc_pdo_sqlstate($this->conn) !== "00000") { + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + return $_ibmText; + } + if ($attribute == 2561 && elephc_pdo_driver_name($this->conn) === "ibm") { + $_trusted = elephc_pdo_ibm_attribute_int($this->conn, $attribute); + if ($_trusted < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + if ($_trusted === 1) { + return true; + } + // PDO_IBM 1.7.0 intentionally falls through to ATTR_TRUSTED_CONTEXT_USERID + // when trusted context is disabled; preserve that observable upstream bug. + $_trustedUser = elephc_pdo_ibm_attribute_text($this->conn, 2562); + if (elephc_pdo_sqlstate($this->conn) !== "00000") { + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + return $_trustedUser; + } + if (($attribute == 0 || $attribute == 1 || $attribute == 1004) && elephc_pdo_driver_name($this->conn) === "oci") { + $_ociValue = elephc_pdo_oci_attribute_int($this->conn, $attribute); + if ($_ociValue >= 0) { + return $attribute == 0 ? ($_ociValue === 1) : $_ociValue; + } + } + if ((($attribute == 0 || $attribute == 1001) && elephc_pdo_driver_name($this->conn) === "odbc") || (($attribute == 0 || $attribute == 14 || $attribute == 1003 || $attribute == 1007) && elephc_pdo_driver_name($this->conn) === "firebird")) { + if (elephc_pdo_driver_name($this->conn) === "odbc") { + $_odbcValue = elephc_pdo_odbc_attribute($this->conn, $attribute); + if ($_odbcValue >= 0) { + return $_odbcValue === 1; + } + } + $_firebirdValue = elephc_pdo_firebird_attribute_int($this->conn, $attribute); + if ($_firebirdValue >= 0) { + return ($attribute == 0 || $attribute == 14 || $attribute == 1007) ? ($_firebirdValue === 1) : $_firebirdValue; + } + } + if (($attribute == 1000 || $attribute == 1001 || $attribute == 1002) && elephc_pdo_driver_name($this->conn) === "firebird") { + return elephc_pdo_firebird_attribute_text($this->conn, $attribute); + } + if (($attribute == 0 || $attribute == 2 || $attribute == 1000 || $attribute == 1001 || $attribute == 1002) && elephc_pdo_driver_name($this->conn) === "cubrid") { + $_cubridValue = elephc_pdo_cubrid_attribute($this->conn, $attribute); + if ($_cubridValue >= -1 && ($attribute == 2 || $_cubridValue >= 0)) { + return $attribute == 0 ? ($_cubridValue === 1) : $_cubridValue; + } + } + if ($attribute == 0 && (elephc_pdo_driver_name($this->conn) === "mysql" || elephc_pdo_driver_name($this->conn) === "odbc" || elephc_pdo_driver_name($this->conn) === "informix" || elephc_pdo_driver_name($this->conn) === "ibm" || elephc_pdo_driver_name($this->conn) === "oci")) { + return elephc_pdo_autocommit($this->conn) === 1; + } + if ($attribute == 14 && elephc_pdo_driver_name($this->conn) === "mysql") { + return elephc_pdo_fetch_table_names($this->conn) === 1; + } + if ($attribute == 1000 && elephc_pdo_driver_name($this->conn) === "mysql") { + return elephc_pdo_buffered_query($this->conn) === 1; + } + if ($attribute == 3) { + return $this->errMode; + } + if ($attribute == 12) { + return $this->persistent; + } + if ($attribute == 13) { + return $this->statementClassConfig; + } + if ($attribute == 16) { + return elephc_pdo_driver_name($this->conn); + } + if ($attribute == 7 && elephc_pdo_driver_name($this->conn) === "firebird") { + return elephc_pdo_connection_status($this->conn) === "1"; + } + if ($attribute == 19) { + return $this->defaultFetchMode; + } + if ($attribute == 17) { + return $this->stringifyFetches; + } + if ($attribute == 21 && (elephc_pdo_driver_name($this->conn) === "mysql" || elephc_pdo_driver_name($this->conn) === "dblib" || elephc_pdo_driver_name($this->conn) === "sqlsrv")) { + return $this->defaultStrParam; + } + if ($attribute == 20 && elephc_pdo_driver_name($this->conn) === "dblib") { + return true; + } + if ($attribute == 20 && (elephc_pdo_driver_name($this->conn) === "mysql" || elephc_pdo_driver_name($this->conn) === "pgsql" || elephc_pdo_driver_name($this->conn) === "sqlsrv")) { + return $this->emulatePrepares; + } + if ($attribute == 1000 && elephc_pdo_driver_name($this->conn) === "pgsql") { + return $this->disablePrepares; + } + if ($attribute == 1004 && elephc_pdo_driver_name($this->conn) === "mysql") { + return $this->emulatePrepares; + } + if ($attribute == 8) { + return $this->attrCase; + } + if ($attribute == 11) { + return $this->oracleNulls; + } + if ($attribute == 4 && elephc_pdo_driver_name($this->conn) !== "dblib" && elephc_pdo_driver_name($this->conn) !== "informix" && elephc_pdo_driver_name($this->conn) !== "ibm") { + return elephc_pdo_server_version($this->conn); + } + if ($attribute == 1005 && elephc_pdo_driver_name($this->conn) === "sqlite") { + return elephc_pdo_transaction_mode($this->conn); + } + if ($attribute == 5 && elephc_pdo_driver_name($this->conn) !== "dblib") { + return elephc_pdo_client_version($this->conn); + } + if ($attribute == 1002 && elephc_pdo_driver_name($this->conn) === "dblib") { + return elephc_pdo_dblib_attribute_bool($this->conn, $attribute) === 1; + } + if ($attribute == 1003 && elephc_pdo_driver_name($this->conn) === "dblib") { + return elephc_pdo_client_version($this->conn); + } + if ($attribute == 1004 && elephc_pdo_driver_name($this->conn) === "dblib") { + return elephc_pdo_server_version($this->conn); + } + if (($attribute == 1005 || $attribute == 1006) && elephc_pdo_driver_name($this->conn) === "dblib") { + return elephc_pdo_dblib_attribute_bool($this->conn, $attribute) === 1; + } + if ($attribute == 6 && elephc_pdo_driver_name($this->conn) !== "sqlite" && elephc_pdo_driver_name($this->conn) !== "dblib") { + $serverInfo = elephc_pdo_server_info($this->conn); + if ($serverInfo === "") { + $this->failCode("HY000", "failed to read server information"); + return false; + } + return $serverInfo; + } + if ($attribute == 7 && elephc_pdo_driver_name($this->conn) !== "sqlite" && elephc_pdo_driver_name($this->conn) !== "dblib" && elephc_pdo_driver_name($this->conn) !== "odbc" && elephc_pdo_driver_name($this->conn) !== "informix" && elephc_pdo_driver_name($this->conn) !== "ibm" && elephc_pdo_driver_name($this->conn) !== "oci" && elephc_pdo_driver_name($this->conn) !== "cubrid") { + return elephc_pdo_connection_status($this->conn); + } + // Pdo\Sqlite::ATTR_EXTENDED_RESULT_CODES is write-only. Its get hook returns + // unsupported, so it deliberately falls through to IM001 like php-src. + // F-CORE-05: php-src's getAttribute fall-through — IM001 "driver does not support + // that attribute" once the generic switch AND the driver hook have both declined + // (pdo_dbh.c's `case 0:` arm), returning FALSE (php-src's literal `RETURN_FALSE`, + // not NULL). errMode-aware like every other synthetic failure: ERRMODE_SILENT and + // ERRMODE_WARNING still get `false` back rather than a throw. Unlike setAttribute's + // IM001 (see the divergence note there), THIS one is exactly what real PHP does: + // `(new PDO("sqlite::memory:"))->getAttribute(9999)` on a real 8.5.6 CLI throws + // `SQLSTATE[IM001] … driver does not support that attribute`. + // + $this->failCode("IM001", "driver does not support that attribute"); + return false; + } + + public function exec(string $statement): int|bool { + // F-CORE-21/P2-f: real PHP validates this before any driver call at all — + // php-src's PHP_METHOD(PDO, exec) raises the ValueError from its own + // argument check, exactly like the prepare() guard just below (which this + // method was inconsistently missing, so `exec("")` reached the bridge). + if ($statement === "") { + throw new ValueError("PDO::exec(): Argument #1 (\$statement) must not be empty"); + } + $this->hasOperation = true; + $_affected = elephc_pdo_exec($this->conn, $statement); + if ($_affected < 0) { + $this->throwAuthorizerError("PDO::exec"); + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + return $_affected; + } + + public function prepare(string $query, array $options = []): PDOStatement|bool { + $_operation = $this->prepareOperation; + $this->prepareOperation = "PDO::prepare"; + // P2-f: real PHP validates this before any driver call at all. + if ($query === "") { + throw new ValueError("PDO::prepare(): Argument #1 (\$query) must not be empty"); + } + $_driver = elephc_pdo_driver_name($this->conn); + $_statementConfig = $this->statementClassConfig; + if (array_key_exists(13, $options)) { + $_statementConfig = $this->validateStatementClassConfig($options[13], false); + } + $_statementClass = (string) $_statementConfig[0]; + $_statementStatus = __elephc_pdo_statement_class_status($_statementClass); + if ($_statementStatus == 4 || $_statementStatus == 6) { + throw new Error("Cannot instantiate abstract class " . $_statementClass); + } + $_hasStatementConstructor = $_statementStatus == 5; + if (array_key_exists(1, $_statementConfig) && !$_hasStatementConstructor) { + throw new Error("User-supplied statement does not accept constructor arguments"); + } + $_emulated = $this->emulatePrepares; + $_disable = $this->disablePrepares; + $_scrollable = false; + $_prefetchOverride = -2; + if (array_key_exists(10, $options)) { + $_cursorMode = (int) $options[10]; + if ($_driver === "sqlite" && $_cursorMode !== 0) { + return false; + } + if (($_driver === "pgsql" || $_driver === "odbc" || $_driver === "informix" || $_driver === "ibm" || $_driver === "oci" || $_driver === "sqlsrv" || $_driver === "cubrid") && $_cursorMode === 1) { + $_scrollable = true; + } + } + if (isset($options[20])) { + $_emulated = $this->attrBoolValue($options[20]); + } + if ($_driver === "pgsql" && array_key_exists(1, $options)) { + $_prefetchOverride = $this->attrBoolValue($options[1]) ? 1 : 0; + } elseif ($_driver === "oci" && array_key_exists(1, $options)) { + $_prefetchOverride = $this->attrIntValue($options[1]); + } + if ($_driver === "pgsql" && isset($options[1000])) { + $_disable = $this->attrBoolValue($options[1000]); + } + if ($_driver === "mysql" && isset($options[1004])) { + $_emulated = $this->attrBoolValue($options[1004]); + } + $_simple = (($_driver === "mysql" && $_emulated) || ($_driver === "pgsql" && ($_emulated || $_disable || $_scrollable))) ? 1 : 0; + if ($_driver === "sqlsrv" && $_emulated) { + $_simple = $_simple | 1; + } + if (($_driver === "odbc" || $_driver === "informix" || $_driver === "ibm") && $_scrollable) { + $_simple = 2; + } + if ($_driver === "sqlsrv" && $_scrollable) { + $_simple = $_simple | 2; + if (array_key_exists(1003, $options)) { + $_sqlsrvCursorType = $this->attrIntValue($options[1003]); + if ($_sqlsrvCursorType !== 1 && $_sqlsrvCursorType !== 2 + && $_sqlsrvCursorType !== 3 && $_sqlsrvCursorType !== 42) { + $this->failCode("IMSSP", "An invalid statement option was designated."); + return false; + } + $_simple = $_simple | ($_sqlsrvCursorType << 8); + } + } elseif ($_driver === "sqlsrv" && array_key_exists(1003, $options)) { + $this->failCode("IMSSP", "The cursor type must be scrollable to use a scroll type."); + return false; + } + if ($_driver === "sqlsrv") { + $_directQuery = elephc_pdo_odbc_attribute($this->conn, 1002) === 1; + if (array_key_exists(1002, $options)) { + $_directQuery = $this->attrBoolValue($options[1002]); + } + if ($_directQuery) { + $_simple = $_simple | 4; + } + } + $this->hasOperation = true; + $_handle = elephc_pdo_prepare($this->conn, $query, $_simple); + if ($_handle < 0) { + $this->throwAuthorizerError($_operation); + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + if ($_driver === "sqlsrv") { + foreach ([1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009] as $_sqlsrvOption) { + if (!isset($options[$_sqlsrvOption])) { + continue; + } + $_sqlsrvRaw = $options[$_sqlsrvOption]; + $_sqlsrvOptionValue = ($_sqlsrvOption == 1002 + || $_sqlsrvOption == 1005 + || $_sqlsrvOption == 1006 + || $_sqlsrvOption == 1007 + || $_sqlsrvOption == 1009) + ? ($this->attrBoolValue($_sqlsrvRaw) ? 1 : 0) + : $this->attrIntValue($_sqlsrvRaw); + if (elephc_pdo_sqlsrv_stmt_configure($_handle, $_sqlsrvOption, $_sqlsrvOptionValue) !== 1) { + $this->failCode("IMSSP", "An invalid statement option was designated."); + return false; + } + } + } + if ($_prefetchOverride != -2) { + elephc_pdo_stmt_set_prefetch($_handle, $_prefetchOverride); + } + // -- elephc PHP >= 8.5 PDO pgsql simple streaming begin -- + if ($_driver === "pgsql") { + elephc_pdo_stmt_enable_simple_streaming($_handle); + } + // -- elephc PHP >= 8.5 PDO pgsql simple streaming end -- + // Inherit the connection's default fetch mode (ATTR_DEFAULT_FETCH_MODE) so + // a statement fetched with no explicit mode uses the dbh default. + $_stmt = __elephc_new_without_constructor($_statementClass); + __elephc_initialize_pdo_statement($_stmt, $_handle, $this->conn, $this->errMode, $query); + // P1-j: root the owning PDO (and its bridge connection) on the new + // statement so it survives past the scope of any local variable + // holding this PDO — see PDOStatement::$owner / setOwner(). + $_stmt->setOwner($this); + // P3: propagates the raw stored default, bypassing setFetchMode()'s own + // argument validation — see setDefaultFetchMode()'s comment for why a + // prepare()-time call must not run through that validation. + $_stmt->setDefaultFetchMode($this->defaultFetchMode); + // P1-11: inherit ATTR_STRINGIFY_FETCHES the same way (a prepare()-time + // snapshot, not a live read — see the property comment on + // $stringifyFetches above). + $_stmt->setStringifyFetches($this->stringifyFetches); + $_stmt->setDefaultStrParam($this->defaultStrParam); + // P1-i: snapshot ATTR_EMULATE_PREPARES the same way, so + // PDOStatement::getAttribute(ATTR_EMULATE_PREPARES) answers from the + // owning connection's stored value (or false when never set) instead of + // raising IM001 like every other unsupported statement attribute. + $_stmt->setEmulatePrepares(($_simple & 1) === 1); + // P2-e: snapshot ATTR_CASE / ATTR_ORACLE_NULLS the same way (see the + // property comments on $attrCase/$oracleNulls above). + $_stmt->setAttrCase($this->attrCase); + $_stmt->setOracleNulls($this->oracleNulls); + $_stmt->setScrollable($_scrollable); + if ($_hasStatementConstructor) { + if (array_key_exists(1, $_statementConfig)) { + __elephc_invoke_pdo_statement_constructor($_statementClass, $_stmt, $_statementConfig[1]); + } else { + __elephc_invoke_pdo_statement_constructor($_statementClass, $_stmt, []); + } + } + // The supported prepare-time protocol attributes were read explicitly above. + // Other options remain driver-owned; no generic attribute bag is consulted. + $_ignoredOptions = $options; + return $_stmt; + } + + public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|bool { + // F-CORE-22: php-src's PHP_METHOD(PDO, query) carries its OWN empty-statement + // check, so this must not be left to the prepare() call below — an empty query + // did throw, but under the wrong method name ("PDO::prepare(): ..."). php-src's + // own message names the argument `$statement` here (the C-level parameter its + // check validates) even though this prelude's parameter is `$query`; keep + // php-src's text verbatim so a caller matching on the message sees real PHP's. + if ($query === "") { + throw new ValueError("PDO::query(): Argument #1 (\$statement) must not be empty"); + } + $this->prepareOperation = "PDO::query"; + $_statement = $this->prepare($query); + if ($_statement === false) { + return false; + } + if ($_statement->execute() === false) { + return false; + } + if ($fetchMode !== null) { + // Explicit (int) cast: the checker does not narrow a `?int` parameter + // to `int` from the `!== null` guard above when it flows into another + // method call's argument, so an uncast $fetchMode fails to type-check + // against setFetchMode()'s `int $mode` parameter. + $_statement->setFetchMode((int) $fetchMode, ...$fetchModeArgs); + } + return $_statement; + } + + public function lastInsertId(?string $name = null): string|bool { + // The name is a sequence for PostgreSQL (`currval($name)`); SQLite and + // MySQL ignore it and return the last rowid / auto-increment id. The text + // bridge is used so oversized PostgreSQL sequence values (which need not + // fit in an i64) round-trip without truncation. + // + // F-CORE-18: php-src's signature is `string|false`. SQLite and MySQL + // return "0" (never "") when there was no insert, and PostgreSQL's + // `lastval()` errors when no sequence has been used in the session + // (SQLSTATE 55000); the bridge reports every such failure — and an + // unknown handle — as "". An empty result is therefore the failure + // sentinel: surface the connection's real error when the driver set one + // (error-mode-aware, via failCode()), else a generic IM001, and return + // false rather than silently handing back "". + $this->hasOperation = true; + $_id = elephc_pdo_last_insert_id_text($this->conn, $name ?? ""); + if ($_id !== "") { + return $_id; + } + $_sqlstate = elephc_pdo_sqlstate($this->conn); + if ($_sqlstate !== "00000") { + $this->failCode($_sqlstate, elephc_pdo_errmsg($this->conn)); + } else { + $this->failCode("IM001", "driver does not support lastInsertId()"); + } + return false; + } + + // -- elephc optional PDO_CUBRID method begin -- + // PDO_CUBRID's driver method. The native extension returns the schema rows + // directly as an array rather than exposing its temporary CCI request handle. + public function cubrid_schema(int $schemaType, ?string $className = null, ?string $attributeName = null): array|bool { + if (elephc_pdo_driver_name($this->conn) !== "cubrid") { + throw new Error("Call to undefined method PDO::cubrid_schema()"); + } + $_handle = elephc_pdo_cubrid_schema($this->conn, $schemaType, $className ?? "", $attributeName ?? ""); + if ($_handle < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + $_statement = __elephc_new_without_constructor("PDOStatement"); + __elephc_initialize_pdo_statement($_statement, $_handle, $this->conn, $this->errMode, "__elephc_cubrid_schema__"); + $_statement->setOwner($this); + $_statement->setDefaultFetchMode(2); + return $_statement->fetchAll(2); + } + // -- elephc optional PDO_CUBRID method end -- + + public function beginTransaction(): bool { + // PHP forbids nesting: starting a transaction while one is active is a + // logic error and throws regardless of the error mode. P1-g: consult the + // driver's LIVE transaction state where one exists, so a transaction + // started by a raw exec("BEGIN") — bypassing this method — is caught + // too, matching php-src asking the driver instead of trusting a + // PHP-side flag. SQLite reads native autocommit; PostgreSQL/MySQL expose + // bridge-maintained state updated after every successful control command. + // -1 remains the unknown-handle fallback. + $_live = elephc_pdo_in_transaction($this->conn); + $_alreadyActive = $_live === 1 || ($_live === -1 && $this->inTxn); + if ($_alreadyActive) { + throw new PDOException("There is already an active transaction"); + } + if (elephc_pdo_begin($this->conn) != 1) { + $this->hasOperation = true; + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + $this->hasOperation = true; + $this->inTxn = true; + return true; + } + + public function commit(): bool { + // Committing without an active transaction is a logic error in PHP. + if (!$this->inTransaction()) { + throw new PDOException("There is no active transaction"); + } + if (elephc_pdo_commit($this->conn) != 1) { + $this->hasOperation = true; + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + $this->hasOperation = true; + $this->inTxn = false; + return true; + } + + public function rollBack(): bool { + // Rolling back without an active transaction is a logic error in PHP. + if (!$this->inTransaction()) { + throw new PDOException("There is no active transaction"); + } + if (elephc_pdo_rollback($this->conn) != 1) { + $this->hasOperation = true; + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + $this->hasOperation = true; + $this->inTxn = false; + return true; + } + + public function inTransaction(): bool { + // P1-g: prefer the driver's LIVE transaction state (matching php-src, + // which asks the driver rather than trusting client-side bookkeeping) — + // this is what makes a transaction started via a raw exec("BEGIN") + // visible here for every supported driver. -1 is retained only as the + // defensive unknown-handle fallback. + $_live = elephc_pdo_in_transaction($this->conn); + if ($_live === 0 || $_live === 1) { + return $_live === 1; + } + return $this->inTxn; + } + + public static function getAvailableDrivers(): array { + $_drivers = []; + $_count = elephc_pdo_available_driver_count(); + for ($_index = 0; $_index < $_count; $_index++) { + $_drivers[] = elephc_pdo_available_driver_name($_index); + } + return $_drivers; + } + + // -- elephc PHP >= 8.4 PDO::connect begin -- + public static function connect(string $dsn, ?string $username = null, #[\SensitiveParameter] ?string $password = null, ?array $options = null): static { + // PHP 8.4 static factory: dispatch on the DSN driver prefix and return an + // instance of the matching driver-specific subclass. Each subclass inherits + // the whole \PDO surface, so the returned object opens the connection and + // behaves exactly like `new PDO($dsn, ...)`; only its concrete class differs, + // so `PDO::connect("sqlite:...") instanceof \Pdo\Sqlite` is true. Declared to + // return the base \PDO because the subclasses ARE \PDO and elephc has no + // `static` return type; the runtime object is the exact subclass. An + // unrecognized prefix throws, matching PHP's "could not find driver". + // + $calledClass = static::class; + $calledStatus = __elephc_pdo_called_class_status($calledClass); + $_operation = $calledClass . "::connect"; + $_dsn = self::resolveDsnAlias($dsn, $_operation); + $_dsn = self::resolveDsnUri($_dsn, $_operation); + $_driver = ""; + $_driverClass = ""; + $_driverStatus = -1; + if (str_starts_with($_dsn, "sqlite:")) { + $_driver = "sqlite"; + $_driverClass = "Pdo\\Sqlite"; + $_driverStatus = 1; + } elseif (str_starts_with($_dsn, "mysql:")) { + $_driver = "mysql"; + $_driverClass = "Pdo\\Mysql"; + $_driverStatus = 2; + } elseif (str_starts_with($_dsn, "pgsql:")) { + $_driver = "pgsql"; + $_driverClass = "Pdo\\Pgsql"; + $_driverStatus = 3; + } + // -- elephc optional PDO_DBLIB connect dispatch begin -- + elseif (str_starts_with($_dsn, "dblib:")) { + $_driver = "dblib"; + $_driverClass = "Pdo\\Dblib"; + $_driverStatus = 4; + } + // -- elephc optional PDO_DBLIB connect dispatch end -- + // -- elephc optional PDO_FIREBIRD connect dispatch begin -- + elseif (str_starts_with($_dsn, "firebird:")) { + $_driver = "firebird"; + $_driverClass = "Pdo\\Firebird"; + $_driverStatus = 5; + } + // -- elephc optional PDO_FIREBIRD connect dispatch end -- + // -- elephc optional PDO_ODBC connect dispatch begin -- + elseif (str_starts_with($_dsn, "odbc:")) { + $_driver = "odbc"; + $_driverClass = "Pdo\\Odbc"; + $_driverStatus = 6; + } + // -- elephc optional PDO_ODBC connect dispatch end -- + // -- elephc optional PDO_IBM connect dispatch begin -- + elseif (str_starts_with($_dsn, "ibm:")) { + $_driver = "ibm"; + $_driverClass = "Pdo\\Ibm"; + $_driverStatus = 7; + } + // -- elephc optional PDO_IBM connect dispatch end -- + // -- elephc optional PDO_OCI connect dispatch begin -- + elseif (str_starts_with($_dsn, "oci:")) { + $_driver = "oci"; + $_driverClass = "PDO"; + $_driverStatus = 0; + } + // -- elephc optional PDO_OCI connect dispatch end -- + // -- elephc optional PDO_SQLSRV connect dispatch begin -- + elseif (str_starts_with($_dsn, "sqlsrv:")) { + $_driver = "sqlsrv"; + $_driverClass = "PDO"; + $_driverStatus = 0; + } + // -- elephc optional PDO_SQLSRV connect dispatch end -- + if ($_driver === "") { + if ($calledStatus === 0) { + throw new PDOException("could not find driver"); + } + throw new PDOException($calledClass . "::connect() cannot be used for connecting to an unknown driver, call PDO::connect() instead"); + } + if ($calledStatus === $_driverStatus) { + return new static($_dsn, $username, $password, $options); + } + if ($calledStatus !== 0) { + throw new PDOException($calledClass . "::connect() cannot be used for connecting to the \"" . $_driver . "\" driver, either call " . $_driverClass . "::connect() or PDO::connect() instead"); + } + if ($_driverStatus === 1) { + return new \Pdo\Sqlite($_dsn, $username, $password, $options); + } + if ($_driverStatus === 2) { + return new \Pdo\Mysql($_dsn, $username, $password, $options); + } + if ($_driverStatus === 3) { + return new \Pdo\Pgsql($_dsn, $username, $password, $options); + } + // -- elephc optional PDO_DBLIB connect construction begin -- + if ($_driverStatus === 4) { + return new \Pdo\Dblib($_dsn, $username, $password, $options); + } + // -- elephc optional PDO_DBLIB connect construction end -- + // -- elephc optional PDO_FIREBIRD connect construction begin -- + if ($_driverStatus === 5) { + return new \Pdo\Firebird($_dsn, $username, $password, $options); + } + // -- elephc optional PDO_FIREBIRD connect construction end -- + // -- elephc optional PDO_ODBC connect construction begin -- + if ($_driverStatus === 6) { + return new \Pdo\Odbc($_dsn, $username, $password, $options); + } + // -- elephc optional PDO_ODBC connect construction end -- + // -- elephc optional PDO_IBM connect construction begin -- + if ($_driverStatus === 7) { + return new \Pdo\Ibm($_dsn, $username, $password, $options); + } + // -- elephc optional PDO_IBM connect construction end -- + return new \PDO($_dsn, $username, $password, $options); + } + // -- elephc PHP >= 8.4 PDO::connect end -- + + protected function connectionId(): int { + // The raw bridge connection handle, exposed to driver subclasses (e.g. + // Pdo\Pgsql::getPid, Pdo\Mysql::getWarningCount) so they can reach the + // connection without widening the private $conn property. Called through + // normal inherited method dispatch, so it reads $conn in the base class's + // own scope. + return $this->conn; + } + + // PHP 8.4 still installs these three pdo_sqlite extension methods on the + // base PDO class. Pdo\Sqlite exposes the modern spellings separately. + public function sqliteCreateCollation(string $name, mixed $callback): bool { + if (!is_callable($callback)) { + throw new TypeError("PDO::sqliteCreateCollation(): Argument #2 (\$callback) must be a valid callback"); + } + $_normalized = __elephc_normalize_callable($callback); + $_descriptor = __elephc_callable_ptr($_normalized); + $_adapter = __elephc_pdo_adapter_addr(0); + if (elephc_pdo_create_collation($this->connectionId(), $name, $_descriptor, $_adapter) !== 1) { + return false; + } + $this->pdoUdfCallbacks["collation:" . strtolower($name)] = $_normalized; + return true; + } + + public function sqliteCreateFunction(string $name, mixed $callback, int $numArgs = -1, int $flags = 0): bool { + if (!is_callable($callback)) { + throw new TypeError("PDO::sqliteCreateFunction(): Argument #2 (\$callback) must be a valid callback"); + } + $_normalized = __elephc_normalize_callable($callback); + $_descriptor = __elephc_callable_ptr($_normalized); + $_adapter = __elephc_pdo_adapter_addr(1); + if (elephc_pdo_create_function($this->connectionId(), $name, $numArgs, $flags, $_descriptor, $_adapter) !== 1) { + return false; + } + $this->pdoUdfCallbacks["function:" . strtolower($name) . ":" . $numArgs . ":scalar"] = $_normalized; + return true; + } + + public function sqliteCreateAggregate(string $name, mixed $step, mixed $finalize, int $numArgs = -1): bool { + if (!is_callable($step) || !is_callable($finalize)) { + throw new TypeError("PDO::sqliteCreateAggregate(): step and finalize must be valid callbacks"); + } + $_normalizedStep = __elephc_normalize_callable($step); + $_normalizedFinal = __elephc_normalize_callable($finalize); + $_stepDesc = __elephc_callable_ptr($_normalizedStep); + $_stepAdapter = __elephc_pdo_adapter_addr(2); + $_finalDesc = __elephc_callable_ptr($_normalizedFinal); + $_finalAdapter = __elephc_pdo_adapter_addr(3); + if (elephc_pdo_create_aggregate($this->connectionId(), $name, $numArgs, $_stepDesc, $_stepAdapter, $_finalDesc, $_finalAdapter) !== 1) { + return false; + } + $_rootKey = "function:" . strtolower($name) . ":" . $numArgs; + $this->pdoUdfCallbacks[$_rootKey . ":step"] = $_normalizedStep; + $this->pdoUdfCallbacks[$_rootKey . ":final"] = $_normalizedFinal; + return true; + } + + // Shared PostgreSQL COPY SQL fragments for PHP 8.4's legacy PDO::pgsql* + // extension methods. + private function pdoPgsqlCopyOptions(string $separator, string $nullAs): string { + $_sep = $separator === "" ? "\t" : substr($separator, 0, 1); + if ($_sep === "\t" && $nullAs === "\\N") { + return ""; + } + $_delim = $_sep === "\t" ? "E'\\t'" : "'" . $_sep . "'"; + $_null = "'" . str_replace("'", "''", $nullAs) . "'"; + return " WITH (DELIMITER " . $_delim . ", NULL " . $_null . ")"; + } + + private function pdoPgsqlCopyTarget(string $tableName, ?string $fields): string { + if ($fields !== null) { + return $tableName . " (" . $fields . ")"; + } + return $tableName; + } + + public function pgsqlCopyFromArray(string $tableName, array $rows, string $separator = "\t", string $nullAs = "\\N", ?string $fields = null): bool { + $_data = implode("\n", $rows) . "\n"; + $_sql = "COPY " . $this->pdoPgsqlCopyTarget($tableName, $fields) . " FROM STDIN" + . $this->pdoPgsqlCopyOptions($separator, $nullAs); + return elephc_pdo_copy_in($this->connectionId(), $_sql, $_data) >= 0; + } + + public function pgsqlCopyFromFile(string $tableName, string $filename, string $separator = "\t", string $nullAs = "\\N", ?string $fields = null): bool { + $_data = file_get_contents($filename); + if ($_data === false) { + return false; + } + $_sql = "COPY " . $this->pdoPgsqlCopyTarget($tableName, $fields) . " FROM STDIN" + . $this->pdoPgsqlCopyOptions($separator, $nullAs); + return elephc_pdo_copy_in($this->connectionId(), $_sql, (string) $_data) >= 0; + } + + public function pgsqlCopyToArray(string $tableName, string $separator = "\t", string $nullAs = "\\N", ?string $fields = null): array|false { + $_sql = "COPY " . $this->pdoPgsqlCopyTarget($tableName, $fields) . " TO STDOUT" + . $this->pdoPgsqlCopyOptions($separator, $nullAs); + $_raw = elephc_pdo_copy_out($this->connectionId(), $_sql); + if ($_raw === "") { + if (elephc_pdo_errcode($this->connectionId()) != 0) { + return false; + } + return []; + } + $_lines = explode("\n", rtrim($_raw, "\n")); + $_out = []; + foreach ($_lines as $_line) { + $_out[] = $_line . "\n"; + } + return $_out; + } + + public function pgsqlCopyToFile(string $tableName, string $filename, string $separator = "\t", string $nullAs = "\\N", ?string $fields = null): bool { + $_sql = "COPY " . $this->pdoPgsqlCopyTarget($tableName, $fields) . " TO STDOUT" + . $this->pdoPgsqlCopyOptions($separator, $nullAs); + $_raw = elephc_pdo_copy_out($this->connectionId(), $_sql); + if ($_raw === "" && elephc_pdo_errcode($this->connectionId()) != 0) { + return false; + } + return file_put_contents($filename, $_raw) !== false; + } + + public function pgsqlLOBCreate(): string|bool { + if (!$this->inTransaction()) { + return false; + } + $_oid = elephc_pdo_lob_create($this->connectionId()); + return $_oid === "" ? false : $_oid; + } + + public function pgsqlLOBOpen(string $oid, string $mode = "rb"): mixed { + return __ElephcPDOPgsqlLobStream::create($this, $this->connectionId(), $oid, $mode); + } + + public function pgsqlLOBUnlink(string $oid): bool { + if (!$this->inTransaction()) { + return false; + } + return elephc_pdo_lob_unlink($this->connectionId(), $oid) === 1; + } + + public function pgsqlGetNotify(int $fetchMode = 0, int $timeoutMilliseconds = 0): mixed { + $_raw = elephc_pdo_get_notify($this->connectionId(), $timeoutMilliseconds); + if ($_raw === "") { + return false; + } + $_parts = explode("\t", $_raw); + $_pid = isset($_parts[1]) ? (int) $_parts[1] : 0; + $_payload = isset($_parts[2]) ? $_parts[2] : ""; + if ($fetchMode == 2) { + return ["message" => $_parts[0], "pid" => $_pid, "payload" => $_payload]; + } + return [$_parts[0], $_pid, $_payload]; + } + + public function pgsqlGetPid(): int { + return elephc_pdo_backend_pid($this->connectionId()); + } + + public function errorCode(): ?string { + // The 5-character SQLSTATE for the connection's last operation. php-src + // returns null before the first operation and "00000" after a success. + if (!$this->hasOperation) { + return null; + } + return elephc_pdo_sqlstate($this->conn); + } + + public function errorInfo(): array { + // PHP's errorInfo() is [SQLSTATE, driver-specific code, message], with + // ["00000", null, null] on success. Every driver surfaces a real SQLSTATE: + // SQLite via a php-src-matching table, MySQL from the ERR packet's + // #-marked field, PostgreSQL from the ErrorResponse 'C' field. + if (!$this->hasOperation) { + return ["", null, null]; + } + $_sqlstate = elephc_pdo_sqlstate($this->conn); + if ($_sqlstate === "00000") { + return ["00000", null, null]; + } + $_message = elephc_pdo_errmsg($this->conn); + if (elephc_pdo_driver_name($this->conn) === "dblib") { + return $this->dblibErrorInfo($_message); + } + return [$_sqlstate, elephc_pdo_errcode($this->conn), $_message]; + } + + public function quote(string $string, int $type = 2): string|bool { + // Driver-aware string-literal quoting. PDO::PARAM_LOB (3, P1-e) selects a + // driver-native binary literal instead of the plain string-escaping path; + // every other $type value is accepted for PHP signature compatibility but + // otherwise ignored, matching php-src's own quoters (which only ever + // special-case PARAM_LOB). Prepared statements remain the recommended + // path; quote() is only safe when it matches the target driver's literal + // syntax, so it branches on the driver name. + $this->hasOperation = true; + $_driver = elephc_pdo_driver_name($this->conn); + if ($_driver === "odbc") { + $this->failCode("IM001", "driver does not support quoting"); + return false; + } + if ($_driver === "mysql") { + if (elephc_pdo_no_backslash_escapes($this->conn) != 0) { + // P1-f (SECURITY): under the MySQL NO_BACKSLASH_ESCAPES sql_mode, + // backslash is a literal character inside a string literal, so + // backslash-escaping is actively unsafe there — an escaped quote + // (\') does not escape at all and lets a crafted string break out + // of the literal. mysqlnd itself switches to quote-doubling-only + // in that mode; mirror that via the bridge's live sql_mode read. + $_s = str_replace("'", "''", $string); + } else { + // MySQL: ''-doubling alone is injectable with a trailing-backslash + // payload, so backslash-escape. Escape the backslash first, then the + // quotes and the control bytes MySQL recognizes in string literals. + $_s = str_replace("\\", "\\\\", $string); + $_s = str_replace("'", "\\'", $_s); + $_s = str_replace("\"", "\\\"", $_s); + $_s = str_replace(chr(0), "\\0", $_s); + $_s = str_replace(chr(10), "\\n", $_s); + $_s = str_replace(chr(13), "\\r", $_s); + $_s = str_replace(chr(26), "\\Z", $_s); + } + $_quoted = "'" . $_s . "'"; + if ($type == 3) { + // PDO::PARAM_LOB (P1-e): mirrors php-src's mysql_handle_quoter, + // which prefixes the escaped literal with the `_binary` charset + // introducer so the byte string is treated as opaque binary data + // rather than reinterpreted under the connection's charset. + return "_binary" . $_quoted; + } + return $_quoted; + } + if ($_driver === "pgsql") { + if ($type == 3) { + // PDO::PARAM_LOB (P1-e): a bytea hex-format literal + // ('\xDEADBEEF...') is always valid regardless of the server's + // bytea_output setting and is binary-safe (an embedded NUL byte + // survives), unlike the standard-conforming-strings-sensitive + // escape path below — mirrors php-src's PQescapeByteaConn call. + return "'\\x" . bin2hex($string) . "'"; + } + // PostgreSQL: double single quotes; if a backslash is present, use the + // E'...' escape-string form so backslashes are taken literally + // regardless of standard_conforming_strings. + $_doubled = str_replace("'", "''", $string); + if (strpos($string, "\\") !== false) { + return "E'" . str_replace("\\", "\\\\", $_doubled) . "'"; + } + return "'" . $_doubled . "'"; + } + if ($_driver === "dblib") { + $_stringFlags = $type & 0x60000000; + $_national = $_stringFlags == 0x40000000 + || ($_stringFlags == 0 && $this->defaultStrParam == 0x40000000); + if ($_stringFlags == 0x20000000) { + $_national = false; + } + return ($_national ? "N" : "") . "'" . str_replace("'", "''", $string) . "'"; + } + if ($_driver === "sqlsrv") { + $_encoding = elephc_pdo_odbc_attribute($this->conn, 1000); + if ($_encoding == 2 || $type == 3) { + return "0x" . strtoupper(bin2hex($string)); + } + $_stringFlags = $type & 0x60000000; + $_national = $_encoding == 65001 + || $_stringFlags == 0x40000000 + || ($_stringFlags == 0 && $this->defaultStrParam == 0x40000000); + if ($_stringFlags == 0x20000000) { + $_national = false; + } + return ($_national ? "N" : "") . "'" . str_replace("'", "''", $string) . "'"; + } + if ($_driver === "cubrid") { + $_length = elephc_pdo_cubrid_quote($this->conn, $string, strlen($string)); + if ($_length < 0) { + $this->fail("CUBRID failed to quote the string"); + return false; + } + return __elephc_ptr_read_string(elephc_pdo_blob_data_ptr(), $_length); + } + // SQLite (and the default): standard SQL ''-doubling is correct, and + // $type is ignored here too — matching php-src's own sqlite quoter, + // which never consults the type argument either. + return "'" . str_replace("'", "''", $string) . "'"; + } + + public function __destruct() { + // Release the bridge connection when the PDO object is collected. An open + // transaction is rolled back first (matching PHP and keeping a persistent + // handle clean when it returns to the pool). The bridge finalizes the + // connection's remaining statements before closing, and treats an + // already-closed handle as a no-op, so the order relative to any surviving + // PDOStatement destructors does not matter. + if ($this->inTxn || elephc_pdo_in_transaction($this->conn) === 1) { + elephc_pdo_rollback($this->conn); + $this->inTxn = false; + } + // Native SQLite registrations contain raw pointers into compiled-PHP + // descriptors. Remove them before object-field cleanup releases the roots; + // persistent bridge handles deliberately survive their final-owner release. + elephc_pdo_clear_callbacks($this->conn); + // -- elephc PHP >= 8.6 persistent pgsql reset -- + elephc_pdo_release($this->conn, 0); + } + + // P2-17: PHP marks PDO uncloneable — `clone $pdo` throws an `Error` before any + // property is copied, rather than producing a second Zend object that shares the + // one bridge connection handle. Without this guard elephc's default shallow clone + // would hand back a second owner of `$this->conn`; whichever copy is destructed + // first closes the connection out from under the survivor. `get_class($this)` + // reports the runtime (possibly driver-subclass) class name, matching PHP's exact + // message on e.g. `clone (new \Pdo\Sqlite(...))`. + public function __clone(): void { + throw new Error("Trying to clone an uncloneable object of class " . get_class($this)); + } + + // F-CORE-15 (SECURITY-adjacent): php-src marks `class PDO` — and PDOStatement — + // `/** @not-serializable */` in ext/pdo/pdo.stub.php, which installs + // zend_class_serialize_deny, so `serialize($pdo)` throws + // `Exception: Serialization of 'PDO' is not allowed`. elephc has no per-class engine + // flag for that, and its serialize() simply WALKED THE PROPERTIES: it emitted a blob + // containing this object's private `$conn` — the raw integer bridge handle — and + // unserialize() handed back a zombie PDO whose handle indexes nothing (every bridge + // call then answers with an unknown-handle sentinel: driver_name "", errcode 0…). + // Silent misbehavior where php-src is loud, and a serialized blob that leaks internal + // handle numbering into whatever store it lands in. + // + // elephc's serialize() DOES honor the magic hooks, so this is enforceable from the + // prelude: __rt_serialize_object consults the per-class `_class_serialize_ptrs` table + // FIRST and falls back to `_class_sleep_ptrs` + // (src/codegen_support/runtime/system/serialize.rs:559-636; both tables are emitted + // per class_id, resolving through the implementing class so subclasses inherit the + // entry — src/codegen_support/runtime/data/user.rs:288-306). BOTH are declared here: + // __serialize() is the one that actually fires today, __sleep() is the fallback the + // runtime reaches when a class has no __serialize(), and declaring both means no + // ordering change in that runtime can ever quietly re-open the property-walk path. + // The throw unwinds out of the runtime's serialize frame through the ordinary + // longjmp-to-handler path, like any exception raised inside a magic method. + // + // get_class($this), not a literal "PDO": php-src's deny handler names the OBJECT's + // class, so `serialize(new \Pdo\Sqlite(...))` reports + // `Serialization of 'Pdo\Sqlite' is not allowed` — and the subclasses inherit these + // two methods, so they get that message for free. The thrown class is a plain + // `Exception` (not PDOException): zend_class_serialize_deny passes a NULL class entry + // to zend_throw_exception_ex, which is the base Exception. + public function __serialize(): array { + throw new Exception("Serialization of '" . get_class($this) . "' is not allowed"); + } + + public function __sleep(): array { + throw new Exception("Serialization of '" . get_class($this) . "' is not allowed"); + } +} + +// PHP's internal FETCH_LAZY row object is represented in userland here because the +// compiler has no native-class registration channel. The object is statement-owned and +// refreshed in place on every lazy fetch, so retained aliases observe the current row just +// like php-src's single `stmt->lazy_object_ref`. Magic property and ArrayAccess dispatch +// defer value lookup until access time. The compiler recognizes PDOStatement's internal +// construction/refresh calls while keeping both hooks private to user code, matching the +// non-public construction and mutation surface exposed by php-src's internal row object. +final class PDORow implements ArrayAccess { + public readonly string $queryString; + private array $columns; + private array $names; + + private function __construct(bool $internal = false, string $queryString = "") { + if (!$internal) { + throw new PDOException("You may not create a PDORow manually"); + } + $this->queryString = $queryString; + $this->columns = []; + $this->names = []; + } + + private function __elephcRefresh(array $columns, array $names): void { + $this->columns = $columns; + $this->names = $names; + } + + public function __get(string $name): mixed { + if (is_numeric($name)) { + return $this->offsetGet((int) $name); + } + $_count = count($this->names); + for ($_i = 0; $_i < $_count; $_i++) { + if ($this->names[$_i] === $name) { + return $this->columns[$_i]; + } + } + return null; + } + + public function __isset(string $name): bool { + return $this->__get($name) !== null; + } + + public function __set(string $name, mixed $value): void { + $_unusedName = $name; + $_unusedValue = $value; + throw new Error("Cannot write to PDORow property"); + } + + public function __unset(string $name): void { + $_unusedName = $name; + throw new Error("Cannot unset PDORow property"); + } + + public function offsetExists(mixed $offset): bool { + return $this->offsetGet($offset) !== null; + } + + public function offsetGet(mixed $offset): mixed { + if (is_int($offset)) { + $_index = (int) $offset; + if ($_index >= 0 && $_index < count($this->columns)) { + return $this->columns[$_index]; + } + return null; + } + return $this->__get((string) $offset); + } + + public function offsetSet(mixed $offset, mixed $value): void { + $_unusedValue = $value; + if ($offset === null) { + throw new Error("Cannot append to PDORow offset"); + } + throw new Error("Cannot write to PDORow offset"); + } + + public function offsetUnset(mixed $offset): void { + $_unusedOffset = $offset; + throw new Error("Cannot unset PDORow offset"); + } + + public function __serialize(): array { + throw new Exception("Serialization of 'PDORow' is not allowed"); + } + + public function __sleep(): array { + throw new Exception("Serialization of 'PDORow' is not allowed"); + } +} + +// PHP exposes PDOStatement through IteratorAggregate. The prefixed helper owns its own +// row/key cursor state and delegates only to PDOStatement::fetch(), so PDOStatement does +// not leak Iterator's rewind/current/key/next/valid methods into its public API. +class PDOStatement implements IteratorAggregate { + private int $stmt; + private int $conn; + private int $errMode; + private int $fetchMode; + private $fetchTarget; + private array $fetchCtorArgs; + private bool $fetchPropsLate; + private array $boundParams; + // F-STMT-12: the placeholder NAME each bind was made with (":name" / "name" exactly as + // the caller spelled it), or "" for a positional bind. $boundParams above records the + // RESOLVED 1-based driver slot, which is all execute() needs but destroys the name + // debugDumpParams() has to print ("Key: Name: [9] :calories"). Kept as a fourth parallel + // array — appended and cleared in lockstep with the other three — rather than folded into + // one array of records, because a per-bind array-of-arrays is exactly the heterogeneous + // Mixed shape that miscompiles here. + private array $boundNames; + private array $boundValues; + private array $boundTypes; + private array $boundDriverOptions; + // F-STMT-12: the PDO::PARAM_* type php-src would REPORT for each bind, which is not + // always the one elephc dispatches on ($boundTypes above). bindValue() records the + // caller's raw $type in both. execute($params) is where they part: php-src's + // pdo_stmt_bind_input_params stamps EVERY element of that array PDO_PARAM_STR (2) — + // regardless of the PHP value's type — while $boundTypes has to keep the per-value + // dispatch tag (1 int/bool, 0 null, 2 string, 100 = internal float marker) so a later + // no-arg execute() re-binds each value with the right driver call. Only + // debugDumpParams() reads this array; nothing binds from it. + private array $boundPhpTypes; + // Append-only bind indexes seen by the driver's execute-time normalization + // hook. Named binds report paramno=-1 until their index appears here. + private array $boundNormalizedIndexes; + // bindParam() reference getters, keyed by the append index in boundValues. + private array $boundParamRefIndexes; + private array $boundParamRefGetters; + // Stream-valued references must be consumed inside their closure. Returning a + // resource through the generic callable ABI creates a temporary Mixed owner whose + // cleanup can close the shared descriptor before the bind loop reads it. + private array $boundParamRefStreamReaders; + private array $boundParamRefSetters; + private array $boundParamMaxLengths; + // bindColumn() keeps its destination alive through a by-reference closure capture. + // Parallel indexed arrays avoid heterogeneous records. Later duplicate keys shadow + // earlier registrations during fetch, matching php-src's replacement semantics. + private array $boundColumnKinds; + private array $boundColumnIndexes; + private array $boundColumnNames; + private array $boundColumnSetters; + private array $boundColumnTypes; + private int $fetchColumn; + private int $rowCount; + private bool $executed; + private bool $hasOperation; + private mixed $lazyRow; + // P1-4: mirrors php-src's pdo_sqlite `pre_fetched` flag — execute() eagerly + // steps a SELECT-style statement once (see execute()'s comment) so + // getColumnMeta() called before any explicit fetch() reports the real + // column types of the first row instead of "no row yet". $pendingStep + // caches that first step's result (elephc_pdo_step()'s return code) so the + // FIRST subsequent stepCursor() call (from fetch()/fetchColumn()/etc.) + // consumes it instead of stepping again, which would otherwise skip row 1. + private bool $hasPendingStep; + private int $pendingStep; + // PostgreSQL `prepare(..., [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL])` enables + // FETCH_ORI_* movement. SQLite rejects the option; MySQL remains forward-only. + private bool $scrollable; + // F-STMT-13: php-src makes $queryString read-only through a custom property-write + // handler (dbstmt_prop_write: `zend_throw_error(NULL, "Property queryString is read + // only")`), so `$stmt->queryString = 'x'` is an Error, not a silent overwrite of the + // SQL the object reports. elephc has no property-write hook, but it DOES have + // `readonly`: assignable once from the declaring class's constructor (the only place + // this is written — see __construct), rejected everywhere else. The SQL a statement + // reports can therefore never be overwritten, which is the point of the finding. + // + // Both a concrete PDOStatement receiver and the `PDOStatement|bool` union returned + // by prepare()/query() raise the catchable Error. The text is PHP's generic readonly + // message ("Cannot modify readonly property PDOStatement::$queryString") rather than + // pdo_stmt.c's custom "Property queryString is read only"; class and catchability match. + public readonly string $queryString; + // Fallback copies used only before setOwner(); normal statements read the + // owning PDO's live values at fetch/description time like php-src's stmt->dbh. + private bool $stringifyFetches; + // MySQL's default string-parameter flag, snapshotted from the connection. + // 0x40000000 selects national `N'…'`; 0x20000000 selects ordinary text. + private int $defaultStrParam; + // P1-i: mirrors PDO::ATTR_EMULATE_PREPARES, snapshotted at prepare() time + // from the owning connection's stored value (see setEmulatePrepares()). + // getAttribute() answers this one attribute from the snapshot instead of + // raising IM001 like every other unsupported statement attribute — no real + // per-statement attribute store exists any more (setAttribute() always + // fails; see its own comment). + private bool $emulatePrepares; + // Fallback copies of PDO::ATTR_CASE / ATTR_ORACLE_NULLS for the brief + // pre-owner initialization path. Normal statement reads stay connection-live. + private int $attrCase; + private int $oracleNulls; + // P1-j: roots the owning PDO object (and, transitively, its bridge + // connection) for as long as this statement is reachable. `$conn` above is + // a bare integer handle into the bridge's connection table — it carries no + // reference of its own — so a statement returned out of the scope that + // opened its connection (e.g. `return $db->query(...)` from inside a + // function whose local `$db` then goes out of scope) would otherwise leave + // `$conn` dangling once the PDO object is collected. A plain object-typed + // property is enough for elephc's refcounting GC to keep the referenced + // PDO (and its connection) alive; see setOwner(), called from + // PDO::prepare(). PDO does not hold a reference back to any of its + // statements, so this creates no reference cycle. + private ?PDO $owner; + + public function __construct(int $handle, int $connection, int $errMode = 2, string $query = "") { + // P2-o: php-src's PDOStatement constructor throws "You should not + // create a PDOStatement manually" when invoked directly rather than + // via PDO::prepare()/PDO::query() (its internal check is that the + // statement has no owning `dbh` yet). elephc's constructor is + // necessarily public — PDO::prepare() constructs this class from a + // different class — and takes bare integer handles with no access + // control to lean on, so the closest honest equivalent is rejecting a + // $connection that is not a real, currently-open connection handle: + // elephc_pdo_driver_name() returns "" for an unknown id, which is + // exactly what a hand-constructed call passing an arbitrary/guessed + // integer hits, since no valid handle is ever exposed to PHP code. + // This does not catch a caller who happens to guess a live handle + // (elephc's handles are small sequential integers), but neither would + // any check short of real access control. + if (elephc_pdo_driver_name($connection) === "") { + throw new PDOException("You should not create a PDOStatement manually"); + } + $this->__elephcInitialize($handle, $connection, $errMode, $query); + } + + // Internal initialization entry used after ATTR_STATEMENT_CLASS allocates a subclass + // without invoking its user constructor. php-src fills the native statement fields and + // queryString first, then invokes the protected/private constructor with user arguments. + private function __elephcInitialize(int $handle, int $connection, int $errMode = 2, string $query = ""): void { + $this->stmt = $handle; + $this->conn = $connection; + $this->errMode = $errMode; + // PHP exposes the prepared SQL as the public PDOStatement::$queryString + // property; thread it through from prepare() so debugDumpParams and callers + // can read it. + $this->queryString = $query; + $this->fetchMode = 4; + $this->fetchTarget = null; + $this->fetchCtorArgs = []; + $this->fetchPropsLate = false; + $this->boundParams = []; + $this->boundNames = []; + $this->boundValues = []; + $this->boundTypes = []; + $this->boundDriverOptions = []; + $this->boundPhpTypes = []; + $this->boundNormalizedIndexes = []; + $this->boundParamRefIndexes = []; + $this->boundParamRefGetters = []; + $this->boundParamRefStreamReaders = []; + $this->boundParamRefSetters = []; + $this->boundParamMaxLengths = []; + $this->boundColumnKinds = []; + $this->boundColumnIndexes = []; + $this->boundColumnNames = []; + $this->boundColumnSetters = []; + $this->boundColumnTypes = []; + $this->fetchColumn = 0; + $this->rowCount = 0; + // Guards fetch*() against stepping a never-executed statement (which would + // silently run the query with NULL binds). Set true by execute(), cleared + // by closeCursor(). + $this->executed = $query === "__elephc_cubrid_schema__"; + $this->hasOperation = false; + $this->lazyRow = null; + $this->hasPendingStep = false; + $this->pendingStep = 0; + $this->scrollable = false; + $this->stringifyFetches = false; + $this->defaultStrParam = 0x20000000; + $this->emulatePrepares = false; + $this->attrCase = 0; + $this->oracleNulls = 0; + $this->owner = null; + } + + // P1-j: called by PDO::prepare() with $this right after construction, so + // the statement roots its owning connection for its whole lifetime (see + // the $owner property comment above). + public function setOwner(PDO $owner): void { + $this->owner = $owner; + } + + public function setStringifyFetches(bool $on): void { + $this->stringifyFetches = $on; + } + + public function setDefaultStrParam(int $type): void { + $this->defaultStrParam = $type; + } + + public function setEmulatePrepares(bool $on): void { + $this->emulatePrepares = $on; + } + + public function setAttrCase(int $mode): void { + $this->attrCase = $mode; + } + + public function setOracleNulls(int $mode): void { + $this->oracleNulls = $mode; + } + + private function currentStringifyFetches(): bool { + if ($this->owner !== null) { + return (bool) $this->owner->getAttribute(PDO::ATTR_STRINGIFY_FETCHES); + } + return $this->stringifyFetches; + } + + private function currentAttrCase(): int { + if ($this->owner !== null) { + return (int) $this->owner->getAttribute(PDO::ATTR_CASE); + } + return $this->attrCase; + } + + private function currentOracleNulls(): int { + if ($this->owner !== null) { + return (int) $this->owner->getAttribute(PDO::ATTR_ORACLE_NULLS); + } + return $this->oracleNulls; + } + + public function setScrollable(bool $scrollable): void { + $this->scrollable = $scrollable; + } + + private function dblibStatementErrorInfo(string $message): array { + $_sqlstate = elephc_pdo_stmt_sqlstate($this->stmt); + $_native = elephc_pdo_stmt_errcode($this->stmt); + $_osCode = elephc_pdo_dblib_stmt_os_errcode($this->stmt); + $_severity = elephc_pdo_dblib_stmt_severity($this->stmt); + $_query = elephc_pdo_stmt_sent_sql($this->stmt); + if ($_query === "") { + $_query = $this->queryString; + } + $_formatted = $message . " [" . $_native . "] (severity " . $_severity . ") [" . $_query . "]"; + $_info = [$_sqlstate, $_native, $_formatted, $_osCode, $_severity]; + $_osMessage = elephc_pdo_dblib_stmt_os_errmsg($this->stmt); + if ($_osMessage !== "") { + $_info[] = $_osMessage; + } + return $_info; + } + + private function fail(string $message): void { + // Per-statement error state (W1): the SQLSTATE, native code, and message + // are read from the statement's own error slots and attached to errorInfo. + if ($this->errMode == 0) { + return; + } + $_sqlstate = elephc_pdo_stmt_sqlstate($this->stmt); + $_native = elephc_pdo_stmt_errcode($this->stmt); + // php-src pdo_handle_error builds "SQLSTATE[%s]: %s: %d %s" (state, + // description, native code, driver message); errorInfo keeps the raw + // [state, native, message] triple frameworks read via $e->errorInfo. + $_errorInfo = [$_sqlstate, $_native, $message]; + if (elephc_pdo_driver_name($this->conn) === "dblib") { + $_errorInfo = $this->dblibStatementErrorInfo($message); + $message = (string) $_errorInfo[2]; + } + $_full = "SQLSTATE[" . $_sqlstate . "]: " . __elephc_pdo_sqlstate_description($_sqlstate) . ": " . $_native . " " . $message; + if ($this->errMode == 2) { + throw PDOException::__elephcFromErrorInfo($_full, $_errorInfo); + } + fwrite(STDERR, "PDO error: " . $_full . "\n"); + } + + // A synthetic (non-driver) statement-level error, e.g. IM001 "driver doesn't + // support ..." or the FETCH_KEY_PAIR column-count check — mirrors php-src's + // `pdo_raise_impl_error`, which writes a caller-given SQLSTATE rather than + // reading the driver's live error state (there was no real query failure to + // read one from). Still fully errMode-aware like fail() above: EXCEPTION + // throws, WARNING writes to stderr, SILENT is quiet — every case leaves the + // caller to return its own failure value. + private function failCode(string $sqlstate, string $message): void { + if ($this->errMode == 0) { + return; + } + $_full = __elephc_pdo_impl_error_message($sqlstate, $message); + if ($this->errMode == 2) { + throw PDOException::__elephcFromErrorInfo($_full, [$sqlstate, 0]); + } + fwrite(STDERR, "PDO error: " . $_full . "\n"); + } + + public function errorCode(): ?string { + // The 5-character SQLSTATE for the statement's last operation. + if (!$this->hasOperation) { + return null; + } + return elephc_pdo_stmt_sqlstate($this->stmt); + } + + public function errorInfo(): array { + // Per-statement [SQLSTATE, native, message], mirroring PDO::errorInfo(). + if (!$this->hasOperation) { + return ["", null, null]; + } + $_sqlstate = elephc_pdo_stmt_sqlstate($this->stmt); + if ($_sqlstate === "00000") { + return ["00000", null, null]; + } + $_message = elephc_pdo_stmt_errmsg($this->stmt); + if (elephc_pdo_driver_name($this->conn) === "dblib") { + return $this->dblibStatementErrorInfo($_message); + } + return [$_sqlstate, elephc_pdo_stmt_errcode($this->stmt), $_message]; + } + + // P3: propagates ATTR_DEFAULT_FETCH_MODE to a freshly prepared statement, + // mirroring php-src's OWN mechanism exactly (verified against pdo_dbh.c: + // `stmt->default_fetch_type = dbh->default_fetch_type;` — a raw field + // copy at statement construction, never routed through + // pdo_stmt_setup_fetch_mode/pdo_stmt_verify_mode at all). This must stay a + // separate, unvalidated setter rather than calling the public + // setFetchMode() below: checkDefaultFetchMode() only rejects + // FETCH_USE_DEFAULT (0), so a bare FETCH_CLASS/FETCH_INTO/FETCH_FUNC is a + // legal STORED default in both php-src and this prelude (P3 relaxed the + // former two to match php-src; FETCH_FUNC was never restricted here + // either). A call through setFetchMode()'s OWN validation (the + // ArgumentCountError-equivalent / FETCH_FUNC checks a few lines down) + // would wrongly reject that otherwise-legal stored default the moment ANY + // statement on the connection is prepared — php-src only re-validates a + // defaulted mode lazily, when fetch()/fetchAll() actually resolves + // PDO_FETCH_USE_DEFAULT (see fetch()'s own + // `if ($mode == 0) { $mode = $this->fetchMode; }` resolution above, which + // already re-runs the FETCH_FUNC/FETCH_LAZY checks at that later point). + public function setDefaultFetchMode(int $mode): void { + $this->fetchMode = $mode; + } + + // F-STMT-17: names the offending value the way php-src's zend_zval_value_name() does in + // an argument TypeError. It is a near-copy of PDO::attrValueTypeName() (see the + // F-CORE-03 comment there) rather than a call to it: that one is `private` on a + // DIFFERENT class, and this prelude has no trait or shared-private mechanism to reach it + // from here — promoting it to `public static` on PDO would bolt a method onto PDO's + // public surface that real PHP does not have, a worse divergence than a short duplicate. + // + // It is NOT a byte-for-byte copy: zend_zval_value_name() spells a bool as "true"/"false" + // (PHP 8.3+), which is what real PHP prints here — verified against php 8.x: + // `setFetchMode(PDO::FETCH_COLUMN, true)` says "must be of type int, true given". The + // PDO-side copy still says "bool"; that is a pre-existing text divergence in the + // attribute TypeErrors, left alone here because its messages are pinned by tests. + // The one approximation left: php names an OBJECT by its class, this reports "object". + private function argValueTypeName(mixed $value): string { + if (is_int($value)) { + return "int"; + } + if (is_bool($value)) { + // Explicit (bool) cast rather than a bare `if ($value)`: the value is a Mixed + // parameter, and every other truthiness test in this prelude casts first. + if ((bool) $value) { + return "true"; + } + return "false"; + } + if (is_float($value)) { + return "float"; + } + if (is_string($value)) { + return "string"; + } + if (is_array($value)) { + return "array"; + } + if (is_null($value)) { + return "null"; + } + return "object"; + } + + private function copyConstructorArgs(mixed $source): array { + $_copy = []; + foreach ($source as $_key => $_value) { + $_copy[$_key] = $_value; + } + return $_copy; + } + + public function setFetchMode(int $mode, mixed ...$args): bool { + $_argCount = count($args); + $classOrColumn = $_argCount > 0 ? $args[0] : null; + $_constructorArgs = $_argCount > 1 ? $args[1] : null; + // P2-d: reject an out-of-range base mode and a negative FETCH_COLUMN + // index BEFORE storing anything (mirrors php-src's pdo_stmt_verify_mode / + // pdo_stmt_setup_fetch_mode ValueErrors), so a rejected call leaves the + // statement's previous fetch mode untouched. OR-able high-bit flags (e.g. + // FETCH_GROUP, FETCH_CLASSTYPE) are masked off first, matching fetch()'s + // own `$mode & 0xFFFF` base-mode masking; 0..12 covers every FETCH_* + // base mode this prelude defines (FETCH_DEFAULT..FETCH_KEY_PAIR). + $_base = $mode & 0xFFFF; + if ($_base < 0 || $_base > 12) { + throw new ValueError("PDOStatement::setFetchMode(): Argument #1 (\$mode) must be a bitmask of PDO::FETCH_* constants"); + } + // P3: php-src's pdo_stmt_setup_fetch_mode calls pdo_stmt_verify_mode + // with fetch_all=false for setFetchMode(), which rejects FETCH_FUNC + // outright (it is valid only as fetchAll()'s first argument) — the + // exact same ValueError text fetch()'s own FETCH_FUNC check above + // throws (verified against php-src: both call sites hit the identical + // `case PDO_FETCH_FUNC: if (!fetch_all) { zend_value_error(...); }`). + // -- elephc PHP >= 8.5 setFetchMode class flags -- + if ($_base == 10) { + throw new ValueError("Can only use PDO::FETCH_FUNC in PDOStatement::fetchAll()"); + } + // F-STMT-09: every gate below tests $_base, the FLAG-MASKED mode — they used to + // test the RAW $mode, which is false the moment ANY high-bit flag is OR-ed in. + // `setFetchMode(PDO::FETCH_CLASS|PDO::FETCH_PROPS_LATE, 'Row')` therefore matched + // no gate at all: the arity checks were skipped AND the class name was dropped on + // the floor by the storage block at the bottom, leaving a statement in FETCH_CLASS + // mode with no target — which then silently fetched stdClass rows. + // F-STMT-17: php-src checks the column argument's TYPE before its RANGE + // (pdo_stmt.c's PDO_FETCH_COLUMN arm: `if (Z_TYPE(args[0]) != IS_LONG) { + // zend_argument_type_error(2, "must be of type int, %s given", ...); }` immediately + // ahead of the `< 0` value check below). The argument is variadic `mixed ...$args` + // in the stub, so it is NEVER juggled: a bool, a float, and even the numeric string + // "3" are all IS_LONG-mismatches and all raise the TypeError — hence the strict + // is_int() here rather than an is_numeric()-style shape test. This prelude used to + // fall straight into the `(int) $classOrColumn` cast below, and `(int) "abc"` is 0, + // so `setFetchMode(PDO::FETCH_COLUMN, "abc")` silently selected column 0 and + // reported success. + // + // The message carries NO argument NAME — "Argument #2 must be of type int, string + // given" — because zend never names a variadic parameter in an argument error + // (verified against real php: `Argument #2 must be of type int, string given`, and + // likewise `Argument #2 must be greater than or equal to 0` for the range error + // below). FOLLOW-UP, deliberately not fixed here: the neighbouring ValueError texts + // in this method DO spell an `($args)` php never prints. Their exact strings are + // pinned by existing tests, so correcting them is a test-touching change and out of + // scope for this one. + if ($_base == 7 && $classOrColumn !== null && !is_int($classOrColumn)) { + throw new TypeError("PDOStatement::setFetchMode(): Argument #2 must be of type int, " . $this->argValueTypeName($classOrColumn) . " given"); + } + if ($_base == 7 && $classOrColumn !== null && ((int) $classOrColumn) < 0) { + throw new ValueError("PDOStatement::setFetchMode(): Argument #2 (\$args) must be greater than or equal to 0"); + } + // F-STMT-09: FETCH_CLASSTYPE reads the class name from COLUMN 0'S VALUE at fetch + // time (see fetch()'s own CLASSTYPE branch), so an explicit class argument is not + // merely redundant — it is a contradiction, and php-src rejects the combination + // outright (pdo_stmt.c:1783-1790: the CLASSTYPE arm of the FETCH_CLASS case takes + // its class from the data and raises zend_argument_count_error the moment a + // variadic class argument accompanies it). This prelude used to accept the combo + // and quietly discard the argument. Same ArgumentCountError-vs-ValueError + // substitution as the arity gates below (elephc has no ArgumentCountError class), + // with php-src's literal message text. + if ($_base == 8 && ($mode & 0x40000) != 0 && $_argCount != 0) { + throw new ValueError("PDOStatement::setFetchMode() expects exactly 1 argument for the fetch mode provided, " . (1 + $_argCount) . " given"); + } + // P3: php-src's pdo_stmt_setup_fetch_mode raises an ArgumentCountError + // when FETCH_COLUMN/FETCH_CLASS/FETCH_INTO is given with no further + // argument at all (verified against php-src's exact wording: "%s() + // expects exactly/at least %d arguments for the fetch mode provided, + // %d given", %s = "PDOStatement::setFetchMode", the argument count + // derived from this method's own arg positions). elephc has no + // ArgumentCountError class (not part of its builtin exception + // hierarchy) and, unlike real PHP's variadic-arity introspection, + // cannot distinguish "argument omitted" from "argument explicitly + // null" on a plain `$classOrColumn = null` default parameter — so this + // raises the closest available ValueError (still catchable via + // `\Error`, just not via a real `\ArgumentCountError`) with php-src's + // literal message text for the omitted case. + if ($_base == 7 && $_argCount != 1) { + throw new ValueError("PDOStatement::setFetchMode() expects exactly 2 arguments for the fetch mode provided, 1 given"); + } + // FETCH_CLASS is the one base mode whose class argument is OPTIONAL — but only + // under CLASSTYPE, which supplies it from the data instead (and which the gate + // above has already proven was NOT accompanied by an explicit one). + if ($_base == 8 && ($mode & 0x40000) == 0 && ($_argCount < 1 || $_argCount > 2)) { + throw new ValueError("PDOStatement::setFetchMode() expects at least 2 arguments for the fetch mode provided, 1 given"); + } + if ($_base == 9 && $_argCount != 1) { + throw new ValueError("PDOStatement::setFetchMode() expects exactly 2 arguments for the fetch mode provided, 1 given"); + } + if ($_base != 7 && $_base != 8 && $_base != 9 && $_argCount != 0) { + throw new ValueError("PDOStatement::setFetchMode() expects exactly 1 argument for the fetch mode provided, " . (1 + $_argCount) . " given"); + } + if ($_base == 8 && $_constructorArgs !== null && !is_array($_constructorArgs)) { + throw new TypeError("PDOStatement::setFetchMode(): Argument #3 must be of type array, " . $this->argValueTypeName($_constructorArgs) . " given"); + } + $this->fetchMode = $mode; + $this->fetchPropsLate = ($mode & 0x100000) != 0; + $this->fetchCtorArgs = []; + if ($_base == 7 && $classOrColumn !== null) { + $this->fetchColumn = (int) $classOrColumn; + } elseif (($_base == 8 || $_base == 9) && $classOrColumn !== null) { + $this->fetchTarget = $classOrColumn; + } + if ($_base == 8 && is_array($_constructorArgs)) { + $this->fetchCtorArgs = $this->copyConstructorArgs($_constructorArgs); + } + return true; + } + + public function bindValue($parameter, $value, int $type = 2): bool { + return $this->bindValueWithDriverOption($parameter, $value, $type, null); + } + + private function bindValueWithDriverOption($parameter, $value, int $type, mixed $driverOption): bool { + // F-STMT-05: php-src's PHP_METHOD(PDOStatement, bindValue) validates the + // parameter identifier BEFORE recording anything — a positional slot below 1 + // is a ValueError ("must be greater than or equal to 1"), and an empty named + // placeholder is zend_argument_must_not_be_empty_error(1). This prelude used + // to cast blindly and report success for both, so `bindValue(0, 'x')` bound + // nothing and said it had worked. + if (is_int($parameter)) { + if (((int) $parameter) < 1) { + throw new ValueError("PDOStatement::bindValue(): Argument #1 (\$param) must be greater than or equal to 1"); + } + } elseif (((string) $parameter) === "") { + throw new ValueError("PDOStatement::bindValue(): Argument #1 (\$param) must not be empty"); + } + // Resolve the 1-based slot index now and record it. The named-placeholder + // lookup must not be interleaved with value binds in execute()'s loop: a + // loop body that branches between "lookup index" and "no lookup" corrupts + // a sibling bind in generated code. Recording resolved int slots keeps + // execute()'s bind loop uniform. F-STMT-12: the caller's spelling of the + // placeholder is recorded alongside it ("" for a positional bind) — the resolved + // slot alone cannot reproduce debugDumpParams()'s "Key: Name:" block. + if (is_int($parameter)) { + $_slot = (int) $parameter; + $_pname = ""; + } else { + $_slot = (int) elephc_pdo_bind_parameter_index($this->stmt, (string) $parameter); + $_pname = (string) $parameter; + } + $this->boundParams[] = $_slot; + $this->boundNames[] = $_pname; + $this->boundValues[] = $value; + $this->boundTypes[] = $type; + $this->boundDriverOptions[] = $driverOption; + // F-STMT-12: php-src reports a bindValue()/bindParam() bind with the type the + // caller passed, flags and all (param->param_type is stored verbatim) — so the + // reported type and the dispatch type are the same value on this path. + $this->boundPhpTypes[] = $type; + return true; + } + + public function bindParam($parameter, mixed &$variable, int $type = 2, int $maxLength = 0, mixed $driverOptions = null): bool { + // F-STMT-05: php-src validates bindParam()'s own Argument #1 exactly as it + // validates bindValue()'s, so the guard is repeated here rather than left to + // the bindValue() delegation below — otherwise the ValueError would name the + // wrong method. + if (is_int($parameter)) { + if (((int) $parameter) < 1) { + throw new ValueError("PDOStatement::bindParam(): Argument #1 (\$param) must be greater than or equal to 1"); + } + } elseif (((string) $parameter) === "") { + throw new ValueError("PDOStatement::bindParam(): Argument #1 (\$param) must not be empty"); + } + // Capture a getter over the caller's durable reference cell. The ordinary + // bindValue bookkeeping supplies slot/name/type metadata; execute() replaces + // its stored snapshot with this getter's current value immediately before bind. + $_ok = $this->bindValueWithDriverOption($parameter, $variable, $type, $driverOptions); + $_boundIndex = count($this->boundValues) - 1; + $_getter = function() use (&$variable): mixed { + return $variable; + }; + $_streamReader = function() use (&$variable): mixed { + if (!is_resource($variable)) { + return null; + } + $_contents = stream_get_contents($variable); + if ($_contents === false) { + return false; + } + return (string) $_contents; + }; + $_setter = function(mixed $_value) use (&$variable): void { + $variable = $_value; + }; + $this->boundParamRefIndexes[] = $_boundIndex; + $this->boundParamRefGetters[] = $_getter; + $this->boundParamRefStreamReaders[] = $_streamReader; + $this->boundParamRefSetters[] = $_setter; + $this->boundParamMaxLengths[] = $maxLength; + return $_ok; + } + + public function bindColumn(string|int $column, string|int|float|bool|null &$var, int $type = 2, int $maxLength = 0, mixed $driverOptions = null): bool { + // F-STMT-05: php-src validates bindColumn()'s Argument #1 with the same two + // checks bindValue()/bindParam() get, and it does so during parameter + // validation — i.e. AHEAD of any driver dispatch. So the ValueError must win + // over the not-supported PDOException below, keeping the failure a caller + // sees for a malformed argument identical to real PHP's. + if (is_int($column) && ((int) $column) < 1) { + throw new ValueError("PDOStatement::bindColumn(): Argument #1 (\$column) must be greater than or equal to 1"); + } + if (is_string($column) && ((string) $column) === "") { + throw new ValueError("PDOStatement::bindColumn(): Argument #1 (\$column) must not be empty"); + } + // The PHP subset cannot store `=&` into a property, but its closure + // environments do own durable reference cells. Capture the destination by + // reference and retain that setter on the statement; every successful cursor + // advance invokes it with the freshly converted column value. + // The direct null-preserving branch also makes the frontend's local-use analysis + // see the by-reference parameter; closure captures are intentionally not counted + // by that warning pass yet. + if (is_null($var)) { + $var = null; + } + $_setter = function(string|int|float|bool|null $_value) use (&$var): void { + $var = $_value; + }; + if (is_int($column)) { + $this->boundColumnKinds[] = 0; + $this->boundColumnIndexes[] = (int) $column; + $this->boundColumnNames[] = ""; + } else { + $this->boundColumnKinds[] = 1; + $this->boundColumnIndexes[] = 0; + $this->boundColumnNames[] = (string) $column; + } + $this->boundColumnSetters[] = $_setter; + $this->boundColumnTypes[] = $type; + $_unusedMaxLength = $maxLength; + $_unusedDriverOptions = $driverOptions; + return true; + } + + // Copies completed native input/output buffers back into the durable reference + // cells captured by bindParam(). Scalar outputs intentionally remain strings, + // matching the PDO_OCI and CLI driver post-execute callbacks. + private function syncOutputParameters(): void { + $_refCount = count($this->boundParamRefIndexes); + for ($_ri = 0; $_ri < $_refCount; $_ri++) { + $_boundIndex = (int) $this->boundParamRefIndexes[$_ri]; + $_slot = (int) $this->boundParams[$_boundIndex]; + $_length = elephc_pdo_output_data($this->stmt, $_slot); + if ($_length == -3) { + continue; + } + $_setter = $this->boundParamRefSetters[$_ri]; + if (is_callable($_setter)) { + callable $_typedSetter = $_setter; + if ($_length == -2) { + call_user_func_array($_typedSetter, [null]); + } else { + $_bytes = ""; + if ($_length > 0) { + $_bytes = __elephc_ptr_read_string(elephc_pdo_blob_data_ptr(), $_length); + } + if (elephc_pdo_output_is_lob($this->stmt, $_slot) != 0) { + $_stream = fopen("php://memory", "r+"); + fwrite($_stream, $_bytes); + rewind($_stream); + call_user_func_array($_typedSetter, [$_stream]); + } elseif (elephc_pdo_driver_name($this->conn) === "ibm" && elephc_pdo_output_is_numeric($this->stmt, $_slot) != 0 && (((int) $this->boundTypes[$_boundIndex] & 0xFFFF) == PDO::PARAM_INT)) { + call_user_func_array($_typedSetter, [(int) $_bytes]); + } elseif (elephc_pdo_driver_name($this->conn) === "ibm" && elephc_pdo_output_is_numeric($this->stmt, $_slot) != 0 && (((int) $this->boundTypes[$_boundIndex] & 0xFFFF) == PDO::PARAM_BOOL)) { + call_user_func_array($_typedSetter, [(bool) ((int) $_bytes)]); + } elseif (elephc_pdo_driver_name($this->conn) === "informix" && (((int) $this->boundTypes[$_boundIndex] & 0xFFFF) == PDO::PARAM_INT)) { + call_user_func_array($_typedSetter, [(int) $_bytes]); + } elseif (elephc_pdo_driver_name($this->conn) === "informix" && (((int) $this->boundTypes[$_boundIndex] & 0xFFFF) == PDO::PARAM_BOOL)) { + call_user_func_array($_typedSetter, [(bool) ((int) $_bytes)]); + } else { + call_user_func_array($_typedSetter, [$_bytes]); + } + } + } + } + } + + public function execute(?array $params = null): bool { + $this->executed = true; + $this->hasOperation = true; + elephc_pdo_reset($this->stmt); + elephc_pdo_clear_bindings($this->stmt); + $this->hasPendingStep = false; + $this->pendingStep = 0; + // F-STMT-06 / F-PARSE-06: neither replay loop below used to check the + // resolved slot index OR any elephc_pdo_bind_* return code, so a named + // placeholder the prepared SQL never declares (bind_parameter_index() + // returns 0 for "unknown") and an out-of-range positional slot (every + // bind_* returns 0 there — the driver's own bounds check / SQLITE_RANGE) + // both bound NOTHING while execute() reported success, silently dropping + // the value. php-src raises HY093 for both. Each loop records the failure + // in $_bindError and breaks; it is reported once past the branch, so the + // errMode-aware error path is shared and neither loop body has to unwind + // out of its own iteration. + $_bindError = ""; + // P1-c: php-src's PHP_METHOD(PDOStatement, execute) REPLACES the bound + // parameters with $input_params when it is given — it never layers the + // call-time array on top of earlier bindValue()/bindParam() bindings, so + // a slot bound earlier but absent from $params must NOT keep its stale + // value. Hence these two branches are mutually exclusive: the recorded + // bindValue()/bindParam() bindings replay ONLY when no $params array is + // given at all. + if ($params === null) { + // Apply bindValue()/bindParam() bindings recorded since construction + // (or, per the P2 comment below, the last execute($params) array). + // Slots are already resolved to ints, so this loop never looks up an + // index (keeping the body uniform across positional and named binds). + $_count = count($this->boundParams); + for ($_i = 0; $_i < $_count; $_i++) { + $_slot = (int) $this->boundParams[$_i]; + $_value = $this->boundValues[$_i]; + $_isRefBind = false; + $_refWasStream = false; + $_refMaxLength = 0; + $_refCount = count($this->boundParamRefIndexes); + for ($_ri = 0; $_ri < $_refCount; $_ri++) { + if ($this->boundParamRefIndexes[$_ri] == $_i) { + $_isRefBind = true; + $_refMaxLength = (int) $this->boundParamMaxLengths[$_ri]; + $_streamReader = $this->boundParamRefStreamReaders[$_ri]; + if (is_callable($_streamReader)) { + callable $_typedStreamReader = $_streamReader; + $_streamContents = call_user_func_array($_typedStreamReader, []); + if ($_streamContents === false) { + $_bindError = "__elephc_pdo_driver_error"; + break; + } + if ($_streamContents !== null) { + $_value = (string) $_streamContents; + $_refWasStream = true; + } + } + if (!$_refWasStream) { + $_getter = $this->boundParamRefGetters[$_ri]; + if (is_callable($_getter)) { + callable $_typedGetter = $_getter; + $_value = call_user_func_array($_typedGetter, []); + } + } + break; + } + } + if ($_bindError !== "") { + break; + } + // F-STMT-08: php-src ALWAYS reduces a bound type to its base type + // before dispatching on it — PDO_PARAM_TYPE(x) is + // `((x) & ~PDO_PARAM_FLAGS)` with PDO_PARAM_FLAGS = 0xFFFF0000, the + // high half where PARAM_INPUT_OUTPUT (0x80000000), PARAM_STR_NATL + // (0x40000000) and PARAM_STR_CHAR (0x20000000) live. Dispatching on + // the RAW value made `PDO::PARAM_INT|PDO::PARAM_INPUT_OUTPUT` match + // no branch at all and fall through to the generic TEXT one, binding + // an int as a string. The raw value stays in $this->boundTypes (it is + // what a caller bound); only the dispatch is masked. Same `& 0xFFFF` + // base-mode idiom the fetch-mode paths already use. + $_rawBindType = (int) $this->boundTypes[$_i]; + $_btype = $_rawBindType & 0xFFFF; + $_driverOption = $this->boundDriverOptions[$_i]; + if ($_slot < 1) { + // bindValue()/bindParam() now reject a positional slot below 1 up + // front, so a slot of 0 reaching here can only be a NAMED + // placeholder that bind_parameter_index() could not resolve — + // php-src's "parameter was not defined" flavor of HY093. + $_bindError = "parameter was not defined"; + break; + } + $_brc = 0; + $_driverName = elephc_pdo_driver_name($this->conn); + if ($_driverName === "cubrid" && is_array($_value)) { + $_setFrame = (string) count($_value) . ":"; + foreach ($_value as $_setValue) { + $_setString = (string) $_setValue; + $_setFrame .= (string) strlen($_setString) . ":" . $_setString; + } + $_cubridType = $_driverOption === null ? "" : (string) $_driverOption; + $_brc = elephc_pdo_cubrid_bind_typed($this->stmt, $_slot, $_setFrame, strlen($_setFrame), $_cubridType, 1, $_btype); + } elseif ($_driverName === "cubrid" && $_driverOption !== null) { + $_cubridType = (string) $_driverOption; + $_typedValue = ""; + if (strtoupper($_cubridType) === "BLOB" || strtoupper($_cubridType) === "CLOB") { + if ($_refWasStream) { + $_typedValue = (string) $_value; + } elseif (is_resource($_value)) { + $_streamValue = stream_get_contents($_value); + if ($_streamValue === false) { + $_bindError = "__elephc_pdo_driver_error"; + break; + } + $_typedValue = (string) $_streamValue; + } else { + $_fileValue = file_get_contents((string) $_value); + if ($_fileValue === false) { + $_bindError = "__elephc_pdo_driver_error"; + break; + } + $_typedValue = (string) $_fileValue; + } + } else { + $_typedValue = (string) $_value; + } + $_brc = elephc_pdo_cubrid_bind_typed($this->stmt, $_slot, $_typedValue, strlen($_typedValue), $_cubridType, 0, $_btype); + } elseif ($_btype == 0 || is_null($_value)) { + $_brc = elephc_pdo_bind_null($this->stmt, $_slot); + } elseif ($_btype == 1) { + $_brc = elephc_pdo_bind_int($this->stmt, $_slot, (int) $_value); + } elseif ($_btype == 5) { + // F-STMT-07: PDO::PARAM_BOOL gets the driver's own boolean bind + // (php-src's PDO_PARAM_BOOL case) instead of being folded into + // PARAM_INT — that is what makes PostgreSQL send a real 't'/'f' + // for a BOOL column rather than an integer literal it will refuse. + // The value is truthiness-reduced first, mirroring the zval_is_true() + // php-src applies to this parameter type (so a bound `5` binds + // true, not 5). SQLite/MySQL bind it as 0/1, exactly as before. + $_bval = ((bool) $_value) ? 1 : 0; + $_brc = elephc_pdo_bind_bool($this->stmt, $_slot, $_bval); + } elseif ($_btype == 3) { + // PDO::PARAM_LOB: route through bind_blob (raw bytes, embedded + // NUL preserved) rather than bind_text. + if ($_refWasStream) { + $_s = (string) $_value; + } elseif ($_driverName === "cubrid" && !is_resource($_value)) { + $_lobFileValue = file_get_contents((string) $_value); + if ($_lobFileValue === false) { + $_bindError = "__elephc_pdo_driver_error"; + break; + } + $_s = (string) $_lobFileValue; + } elseif (is_resource($_value)) { + $_lobStreamValue = stream_get_contents($_value); + if ($_lobStreamValue === false) { + $_bindError = "__elephc_pdo_driver_error"; + break; + } + $_s = (string) $_lobStreamValue; + } else { + $_s = (string) $_value; + } + $_brc = elephc_pdo_bind_blob($this->stmt, $_slot, $_s, strlen($_s)); + } elseif ($_btype == 100) { + // P2 (not a real PDO::PARAM_* value): an internal marker + // recorded only by execute($params)'s array-bind rebuild + // below, for a PHP float element, so a later no-arg + // execute() replay re-binds it as a double instead of + // falling into the text branch and stringifying it. + $_brc = elephc_pdo_bind_double($this->stmt, $_slot, (float) $_value); + } else { + // PDO::PARAM_STR (and anything else): bind_text with the + // measured byte length so an embedded NUL byte survives. + $_s = (string) $_value; + $_stringFlags = $_rawBindType & 0x60000000; + $_national = $_stringFlags == 0x40000000 || ($_stringFlags == 0 && $this->defaultStrParam == 0x40000000); + if ($_national) { + $_brc = elephc_pdo_bind_text_national($this->stmt, $_slot, $_s, strlen($_s)); + } else { + $_brc = elephc_pdo_bind_text($this->stmt, $_slot, $_s, strlen($_s)); + } + } + if ($_brc == 0) { + // The slot resolved but the driver refused it: an out-of-range + // positional index (e.g. bindValue(5, ...) on a 2-placeholder + // statement), which is php-src's bare "Invalid parameter number". + $_bindError = "__elephc_pdo_no_detail"; + break; + } + $_isInputOutput = ($_rawBindType & PDO::PARAM_INPUT_OUTPUT) != 0; + $_driverName = elephc_pdo_driver_name($this->conn); + $_isCliOutput = $_isRefBind && $_refMaxLength > 0 && ($_driverName === "odbc" || $_driverName === "informix" || $_driverName === "ibm"); + $_isNullLobOutput = $_driverName === "oci" && $_isRefBind && $_btype == PDO::PARAM_LOB && is_null($_value); + if ($_isInputOutput || $_isCliOutput || $_isNullLobOutput) { + $_brc = elephc_pdo_bind_output($this->stmt, $_slot, $_rawBindType, $_refMaxLength); + if ($_brc < 0) { + $_bindError = "__elephc_pdo_driver_error"; + break; + } elseif ($_brc == 0) { + $_bindError = "__elephc_pdo_no_detail"; + break; + } + } + $this->boundNormalizedIndexes[] = $_i; + } + } else { + // P2: php-src's pdo_stmt_bind_input_params DESTROYS + // stmt->bound_params and REBUILDS it from $input_params, so a + // LATER no-arg execute() replays THIS array, not whatever + // bindValue()/bindParam() calls preceded it (verified against + // php-src: `bindValue(1,'a'); execute(['b']); execute();` inserts + // 'b' on BOTH calls in real PHP). Clear the recorded-bind + // bookkeeping and rebuild it below from $params, in lockstep with + // the driver binds, so the replay loop above sees exactly this + // call's array on a subsequent no-arg execute(). + $this->boundParams = []; + $this->boundNames = []; + $this->boundValues = []; + $this->boundTypes = []; + $this->boundDriverOptions = []; + $this->boundPhpTypes = []; + $this->boundNormalizedIndexes = []; + $this->boundParamRefIndexes = []; + $this->boundParamRefGetters = []; + $this->boundParamRefStreamReaders = []; + $this->boundParamRefSetters = []; + $this->boundParamMaxLengths = []; + // Apply this call's parameter array (positional ? and named :name). + foreach ($params as $_key => $_pv) { + if (is_int($_key)) { + $_idx = $_key + 1; + // F-STMT-12: no name for a positional element, exactly as php-src's + // pdo_stmt_bind_input_params leaves param->name NULL for an integer key. + $_pname = ""; + } else { + $_idx = elephc_pdo_bind_parameter_index($this->stmt, (string) $_key); + // php-src records the array key VERBATIM (with or without its leading + // colon — it tries both spellings when binding), so record it verbatim. + $_pname = (string) $_key; + } + $_pslot = (int) $_idx; + if ($_pslot < 1) { + // F-STMT-06: the same unresolvable-name case as the replay loop + // above — an `execute([':nope' => 1])` key the prepared SQL does + // not declare resolves to slot 0 and used to vanish silently. + $_bindError = "parameter was not defined"; + break; + } + $_prc = 0; + if (is_int($_pv)) { + $_prc = elephc_pdo_bind_int($this->stmt, $_pslot, (int) $_pv); + $this->boundTypes[] = 1; + } elseif (is_bool($_pv)) { + $_prc = elephc_pdo_bind_int($this->stmt, $_pslot, (int) $_pv); + $this->boundTypes[] = 1; + } elseif (is_float($_pv)) { + $_prc = elephc_pdo_bind_double($this->stmt, $_pslot, (float) $_pv); + // 100: see the replay loop's matching comment above. + $this->boundTypes[] = 100; + } elseif (is_null($_pv)) { + $_prc = elephc_pdo_bind_null($this->stmt, $_pslot); + $this->boundTypes[] = 0; + } else { + // The array-bind path carries no PDO type, so PARAM_STR / + // length-safe TEXT (embedded NUL preserved) is correct here. + $_ps = (string) $_pv; + if ($this->defaultStrParam == 0x40000000) { + $_prc = elephc_pdo_bind_text_national($this->stmt, $_pslot, $_ps, strlen($_ps)); + } else { + $_prc = elephc_pdo_bind_text($this->stmt, $_pslot, $_ps, strlen($_ps)); + } + $this->boundTypes[] = 2; + } + $this->boundDriverOptions[] = null; + $this->boundParams[] = $_pslot; + $this->boundNames[] = $_pname; + $this->boundValues[] = $_pv; + // F-STMT-12: php-src stamps PDO_PARAM_STR (2) on every element of an + // execute($params) array, whatever the PHP value's type — verified against + // real PHP 8.x: `execute([1])` then debugDumpParams() prints param_type=2 + // for the integer. The dispatch tag recorded in $boundTypes above (1/0/100) + // is elephc-internal and must NOT leak into the dump. + $this->boundPhpTypes[] = 2; + if ($_prc == 0) { + // F-PARSE-06: a positional key past the placeholder count (the + // array is 0-based, the slot 1-based) — php-src's bare + // "Invalid parameter number". + $_bindError = "__elephc_pdo_no_detail"; + break; + } + $this->boundNormalizedIndexes[] = count($this->boundValues) - 1; + } + } + if ($_bindError !== "") { + // Nothing has been run on the driver yet, so the statement is NOT + // executed — clear the flag set at the top of this method so a later + // fetch() cannot step a statement whose binds were rejected. failCode() + // is errMode-aware exactly like every other statement failure + // (EXCEPTION throws, WARNING warns, SILENT is quiet); all three modes + // return false from execute() rather than reporting a phantom success. + $this->executed = false; + if ($_bindError === "__elephc_pdo_driver_error") { + $this->failCode(elephc_pdo_stmt_sqlstate($this->stmt), elephc_pdo_stmt_errmsg($this->stmt)); + return false; + } + $_bindDetail = $_bindError === "__elephc_pdo_no_detail" ? "" : $_bindError; + $this->failCode("HY093", $_bindDetail); + return false; + } + // The optional drivers below populate their bridge-owned column metadata only + // when they execute. Run them once here, then inspect the materialized rowset + // shape and cache a first row exactly as the ordinary SELECT prefetch path does. + if (elephc_pdo_driver_name($this->conn) === "dblib" + || elephc_pdo_driver_name($this->conn) === "firebird" + || elephc_pdo_driver_name($this->conn) === "cubrid" + || elephc_pdo_driver_name($this->conn) === "odbc" + || elephc_pdo_driver_name($this->conn) === "informix" + || elephc_pdo_driver_name($this->conn) === "ibm" + || elephc_pdo_driver_name($this->conn) === "sqlsrv" + || elephc_pdo_driver_name($this->conn) === "oci") { + $_step = elephc_pdo_step($this->stmt); + if ($_step < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + $this->rowCount = elephc_pdo_changes($this->conn); + return false; + } + if (elephc_pdo_column_count($this->stmt) > 0) { + $this->pendingStep = $_step; + $this->hasPendingStep = true; + } + // A statement with no result columns (INSERT/UPDATE/DELETE/DDL) is run now. + } elseif (elephc_pdo_column_count($this->stmt) == 0) { + $_step = elephc_pdo_step($this->stmt); + if ($this->owner !== null && elephc_pdo_driver_name($this->conn) === "pgsql") { + $this->owner->__elephcDrainPgsqlNotices(); + } + if ($_step < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + $this->rowCount = elephc_pdo_changes($this->conn); + return false; + } + } else { + // P1-4: a SELECT-style statement (column_count > 0) is pre-stepped + // right here, mirroring php-src's pdo_sqlite `pre_fetched` behavior + // (`pdo_sqlite_stmt_execute` steps unconditionally, regardless of + // statement shape). This makes getColumnMeta() report the real + // column types of the first row even before the caller's first + // explicit fetch(); a fetch() call with no prior stepCursor() + // consumption still sees exactly that first row (see + // stepCursor()), so no row is skipped. A genuine error on this + // first step (e.g. a constraint violation on `INSERT ... RETURNING`) + // fails execute() itself here, exactly like the no-result-columns + // branch above — matching real sqlite, where the very first step + // is where such errors actually surface. + $this->pendingStep = elephc_pdo_step($this->stmt); + $this->hasPendingStep = true; + if ($this->owner !== null && elephc_pdo_driver_name($this->conn) === "pgsql") { + $this->owner->__elephcDrainPgsqlNotices(); + } + if ($this->pendingStep < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + $this->rowCount = elephc_pdo_changes($this->conn); + return false; + } + } + $this->syncOutputParameters(); + // Snapshot the affected-row count now, so rowCount() reports this + // statement's result even if another statement runs on the same + // connection afterward. The bridge's changes() is connection-wide, so + // reading it lazily in rowCount() would otherwise return a later + // statement's count (e.g. PostgreSQL/MySQL overwrite changes() with a + // SELECT's row count). + $this->rowCount = elephc_pdo_changes($this->conn); + // P1-2: real pdo_sqlite always reports rowCount()==0 after a + // column-returning (SELECT-style) statement — sqlite3_changes() is a + // write-count, connection-wide, and unrelated to a SELECT's own results + // even once the SELECT has been (pre-)stepped above, so it would + // otherwise echo an EARLIER statement's write count, e.g. 3 after three + // prior INSERTs. PostgreSQL/MySQL are unaffected: they materialize the + // whole result set above and legitimately set changes() to this + // SELECT's own row count. + if (elephc_pdo_column_count($this->stmt) > 0 && elephc_pdo_driver_name($this->conn) === "sqlite") { + $this->rowCount = 0; + } + return true; + } + + private function columnValue(int $index): mixed { + $_type = elephc_pdo_column_type($this->stmt, $index); + $_stringifyFetches = $this->currentStringifyFetches(); + $_oracleNulls = $this->currentOracleNulls(); + if ($_type == 1) { + $_intVal = elephc_pdo_column_int($this->stmt, $index); + if (elephc_pdo_driver_name($this->conn) === "pgsql" + && elephc_pdo_column_native_type($this->stmt, $index) === "bool") { + return $_intVal != 0; + } + if ($_stringifyFetches) { + return (string) $_intVal; + } + return $_intVal; + } elseif ($_type == 2) { + $_dblVal = elephc_pdo_column_double($this->stmt, $index); + if ($_stringifyFetches) { + return (string) $_dblVal; + } + return $_dblVal; + } elseif ($_type == 5) { + // NULL is never stringified, matching PHP. P2-e: ATTR_ORACLE_NULLS's + // NULL_TO_STRING (2) converts it to "" here, mirroring php-src's + // fetch_value() (its final `oracle_nulls == PDO_NULL_TO_STRING` check). + if ($_oracleNulls == 2) { + return ""; + } + return null; + } + // F-QUAL-01: TEXT/BLOB values are copied out of the bridge in ONE call. This + // is the dispatch point for every fetch path (assoc/num/both/named/obj/class/ + // into/key-pair/fetchColumn), and it used to loop over column_data_byte once + // per byte — N FFI calls, each locking and unlocking the bridge's statement + // table, plus N string concatenations, so an N-byte column cost O(N) FFI and + // built the string in O(N^2). column_data_ptr/column_data_len are the + // length-counted pair (they never go through the NUL-stripping store_cstr) and + // ptr_read_string copies an EXACT byte count with no NUL-termination + // semantics, so this stays byte-exact for values with embedded NUL bytes — + // the sole reason the byte loop existed in the first place. + // + // The $_len == 0 guard is load-bearing, not cosmetic: the bridge returns a + // NULL pointer for an empty buffer (store_bytes) and ptr_read_string fatals on + // a NULL pointer (__rt_ptr_check_nonnull, which runs before the length is even + // looked at), so an empty TEXT column must not reach it. + $_len = elephc_pdo_column_data_len($this->stmt, $index); + $_out = ""; + if ($_len > 0) { + $_out = __elephc_ptr_read_string(elephc_pdo_column_data_ptr($this->stmt, $index), $_len); + } + // P2-e: ATTR_ORACLE_NULLS's NULL_EMPTY_STRING (1) converts an empty + // TEXT/BLOB value to null, mirroring php-src's fetch_value() (its + // `IS_STRING && Z_STRLEN_P(dest) == 0` check, which runs before any + // stringify handling there — moot here since TEXT/BLOB values are never + // stringified by this method). + if ($_oracleNulls == 1 && $_out === "") { + return null; + } + if (elephc_pdo_driver_name($this->conn) === "sqlsrv" + && elephc_pdo_sqlsrv_column_is_datetime($this->stmt, $index) === 1) { + return new DateTime($_out); + } + if ($_type == 4 && (elephc_pdo_driver_name($this->conn) === "pgsql" + || elephc_pdo_driver_name($this->conn) === "informix" + || elephc_pdo_driver_name($this->conn) === "ibm" + || elephc_pdo_driver_name($this->conn) === "oci" + || elephc_pdo_driver_name($this->conn) === "cubrid")) { + $_stream = fopen("php://memory", "r+"); + fwrite($_stream, $_out); + rewind($_stream); + return $_stream; + } + return $_out; + } + + // P2-e: ATTR_CASE-aware column-name accessor — folds the raw bridge name to + // upper/lower case per the statement's stored setting (0 = natural, no + // change). Every branch that uses a column name as an array key or object + // property name goes through this so the fold applies from one place + // (FETCH_ASSOC/FETCH_NAMED/FETCH_BOTH's string-keyed half, FETCH_OBJ/ + // FETCH_CLASS/FETCH_INTO via assignColumns(), and getColumnMeta()'s "name" + // entry) — mirrors php-src's pdo_stmt_describe_columns(), which folds each + // column's name once, shared by every fetch style that reads it. + private function columnName(int $index): string { + $_raw = elephc_pdo_column_name($this->stmt, $index); + $_attrCase = $this->currentAttrCase(); + if ($_attrCase == 1) { + return strtoupper($_raw); + } + if ($_attrCase == 2) { + return strtolower($_raw); + } + return $_raw; + } + + private function assignColumns(mixed $object, int $count): mixed { + return $this->assignColumnsFrom($object, 0, $count); + } + + // The same hydration, but starting at column $start rather than column 0 — the one + // thing FETCH_CLASSTYPE (F-STMT-02), FETCH_GROUP and FETCH_UNIQUE (F-STMT-15) all + // need. Each of those CONSUMES column 0 (as the class name / as the grouping key), + // and php-src then EXCLUDES it from the row it hydrates: do_fetch() literally + // advances its column cursor past it (`fetch_value(stmt, &val, i++, NULL)` for + // CLASSTYPE, pdo_stmt.c:805-829; `i++` after reading the group key, pdo_stmt.c:897-909) + // so the value that became the key never also becomes a property/element. A row whose + // key column was silently re-assigned as data would be wrong in the way that is + // hardest to notice — an extra property nobody asked for. + private function assignColumnsFrom(mixed $object, int $start, int $count): mixed { + for ($_i = $start; $_i < $count; $_i++) { + $_value = $this->columnValue($_i); + $_name = $this->columnName($_i); + $object->{$_name} = $_value; + } + return $object; + } + + private function hydrateClass(string $class, int $start, int $count): mixed { + if ($this->fetchPropsLate) { + return $this->assignColumnsFrom(new $class(...$this->fetchCtorArgs), $start, $count); + } + $_object = $this->assignColumnsFrom(__elephc_new_without_constructor($class), $start, $count); + if (__elephc_class_has_constructor($class)) { + call_user_func_array([$_object, "__construct"], $this->fetchCtorArgs); + } elseif (count($this->fetchCtorArgs) != 0) { + throw new Error("Class " . $class . " does not have a constructor, so you cannot pass any constructor arguments"); + } + return $_object; + } + + // Applies PDO's output-column bindings after a successful cursor advance. Named + // bindings use the post-ATTR_CASE column names, exactly like php-src's column + // description table. A missing name remains inert until a later execution exposes it. + private function updateBoundColumns(): void { + $_columnCount = elephc_pdo_column_count($this->stmt); + $_bindingCount = count($this->boundColumnSetters); + for ($_bi = 0; $_bi < $_bindingCount; $_bi++) { + // PDO keeps only the last registration for a column key. Registrations are + // append-only here so descriptor ownership stays simple; skip any entry that + // has an identical key later in the arrays. + $_shadowed = false; + for ($_bj = $_bi + 1; $_bj < $_bindingCount; $_bj++) { + if ($this->boundColumnKinds[$_bj] == $this->boundColumnKinds[$_bi] + && $this->boundColumnIndexes[$_bj] == $this->boundColumnIndexes[$_bi] + && $this->boundColumnNames[$_bj] === $this->boundColumnNames[$_bi]) { + $_shadowed = true; + break; + } + } + if ($_shadowed) { + continue; + } + + $_columnIndex = -1; + if ($this->boundColumnKinds[$_bi] == 0) { + $_columnIndex = ((int) $this->boundColumnIndexes[$_bi]) - 1; + } else { + $_key = $this->boundColumnNames[$_bi]; + for ($_ci = 0; $_ci < $_columnCount; $_ci++) { + if ($this->columnName($_ci) === $_key) { + $_columnIndex = $_ci; + break; + } + } + } + if ($_columnIndex < 0 || $_columnIndex >= $_columnCount) { + continue; + } + + $_value = $this->columnValue($_columnIndex); + $_type = ((int) $this->boundColumnTypes[$_bi]) & 0xFFFF; + if ($_value !== null) { + if ($_type == 0) { + $_value = null; + } elseif ($_type == 1) { + $_value = (int) $_value; + } elseif ($_type == 2) { + $_value = (string) $_value; + } elseif ($_type == 5) { + $_value = (bool) $_value; + } + } + $_setter = $this->boundColumnSetters[$_bi]; + if (is_callable($_setter)) { + callable $_typedSetter = $_setter; + call_user_func_array($_typedSetter, [$_value]); + } + } + } + + // Advances the cursor and returns elephc_pdo_step()'s result code + // (negative = error, 0 = no more rows, positive = a row is available). + // Every caller that consumes rows from this statement's cursor (fetch(), + // fetchColumn(), fetchObject(), and fetchAll()'s FETCH_KEY_PAIR loop) goes + // through this instead of calling elephc_pdo_step() directly, so that + // execute()'s eager pre-step (see execute()'s comment; P1-4) is consumed + // exactly once instead of being silently skipped past. + private function stepCursor(int $orientation = 0, int $offset = 0): int { + if ($this->scrollable) { + // execute() has already materialized and selected the first row. A normal + // FETCH_ORI_NEXT must consume that pending row instead of advancing to row 2. + // Explicit orientations still reposition through the driver, but they must + // discard the pending marker so a later fetch cannot replay stale state. + if ($this->hasPendingStep) { + $this->hasPendingStep = false; + if ($orientation == 0) { + $_rc = $this->pendingStep; + if ($_rc > 0) { + $this->updateBoundColumns(); + } + return $_rc; + } + } + $_rc = elephc_pdo_step_oriented($this->stmt, $orientation, $offset); + if ($_rc > 0) { + $this->updateBoundColumns(); + } + return $_rc; + } + if ($this->hasPendingStep) { + $this->hasPendingStep = false; + $_rc = $this->pendingStep; + } else { + $_rc = elephc_pdo_step($this->stmt); + } + if ($_rc > 0) { + $this->updateBoundColumns(); + } + return $_rc; + } + + // F-STMT-01: php-src's signature, restored. This method's SECOND PARAMETER USED TO BE + // FABRICATED — a `mixed $classOrObject` that let a caller pass FETCH_CLASS's class or + // FETCH_INTO's object straight to fetch(). Real PDO has NO such facility: the stub is + // fetch(int $mode = PDO::FETCH_DEFAULT, + // int $cursorOrientation = PDO::FETCH_ORI_NEXT, + // int $cursorOffset = 0): mixed + // and position 2 is an INT ORIENTATION, so the invented idiom + // `fetch(PDO::FETCH_CLASS, Row::class)` is a TypeError in real PHP, while the + // LEGITIMATE `fetch($mode, PDO::FETCH_ORI_NEXT)` used to push an int into the class + // slot. Class/object targeting is done EXCLUSIVELY through setFetchMode() beforehand + // (or fetchObject()), so FETCH_CLASS/FETCH_INTO now read $this->fetchTarget and + // nothing else. + // + // Forward-only SQLite/MySQL statements ignore orientation like php-src. PostgreSQL + // scroll cursors honor all FETCH_ORI_* values and the ABS/REL offset. + public function fetch(int $mode = 0, int $cursorOrientation = 0, int $cursorOffset = 0): mixed { + if (!$this->executed) { + return false; + } + if ($mode == 0) { + $mode = $this->fetchMode; + } + // Separate the base fetch mode from the OR-able flags (FETCH_GROUP and + // friends live in the high bits) and dispatch on the base, so a flagged + // mode is not silently treated as FETCH_BOTH. + $_base = $mode & 0xFFFF; + // FETCH_LAZY is valid for fetch() and returns the statement's one reusable + // PDORow object. fetchAll() rejects it separately, matching php-src. + // P0-3: real PHP restricts FETCH_FUNC to fetchAll() and raises exactly + // this ValueError (verified against php-src: `zend_value_error("Can + // only use PDO::FETCH_FUNC in PDOStatement::fetchAll()")`, with no + // "Argument #N" prefix since that helper does not add one) from + // fetch(); fail the same way here instead of falling through to the + // BOTH-shaped default. + if ($_base == 10) { + throw new ValueError("Can only use PDO::FETCH_FUNC in PDOStatement::fetchAll()"); + } + // P1: FETCH_BOUND advances the cursor and reports whether a row was + // available, exactly like php-src's `do_fetch` (`how == PDO_FETCH_BOUND` + // → `RETVAL_TRUE` once the cursor has stepped, so a no-more-rows result + // reports false through the fetch()-level "no row" path instead). + // stepCursor() performs bindColumn() write-back before this branch sees + // the successful result, so FETCH_BOUND itself only returns the row status. + if ($_base == 6) { + $_boundRc = $this->stepCursor($cursorOrientation, $cursorOffset); + if ($_boundRc < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + return $_boundRc != 0; + } + // P1: FETCH_CLASSTYPE (class-from-first-column) is an OR-able flag bit + // that this prelude's `& 0xFFFF` base-mode mask silently drops. Verified + // against php-src's `pdo_stmt_verify_mode`: a base mode of FETCH_CLASS + // jumps straight to its own switch case, skipping the CLASSTYPE check + // entirely (so FETCH_CLASS|FETCH_CLASSTYPE is accepted), while every + // other base mode falls into the `default:` branch, which rejects + // CLASSTYPE with a ValueError. FETCH_PROPS_LATE (constructor-first + // hydration order) is NEVER checked in that function at all — it is not + // a rejection reason for any base mode — and since elephc's FETCH_CLASS + // is already unconditionally ctor-first, honoring + // FETCH_CLASS|FETCH_PROPS_LATE costs nothing, so it is intentionally + // not gated here. + if (($mode & 0x40000) != 0 && $_base != 8) { + throw new ValueError('PDOStatement::fetch(): Argument #1 ($mode) must use PDO::FETCH_CLASSTYPE with PDO::FETCH_CLASS'); + } + $_rc = $this->stepCursor($cursorOrientation, $cursorOffset); + if ($_rc < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + if ($_rc == 0) { return false; } $_count = elephc_pdo_column_count($this->stmt); - if ($mode == 7) { + if ($_base == 1) { + $_lazyValues = []; + $_lazyNames = []; + for ($_li = 0; $_li < $_count; $_li++) { + $_lazyValues[] = $this->columnValue($_li); + $_lazyNames[] = $this->columnName($_li); + } + if (!($this->lazyRow instanceof PDORow)) { + $this->lazyRow = new PDORow(true, $this->queryString); + } + $_lazyRow = $this->lazyRow; + if ($_lazyRow instanceof PDORow) { + PDORow $_typedLazyRow = $_lazyRow; + $_typedLazyRow->__elephcRefresh($_lazyValues, $_lazyNames); + return $_typedLazyRow; + } + return false; + } + if ($_base == 7) { // FETCH_COLUMN: yield a single column's value as a scalar instead of a // row array. The column index defaults to 0 and is set via the second // argument to setFetchMode(PDO::FETCH_COLUMN, $col). return $this->columnValue($this->fetchColumn); } - if ($mode == 5) { - // FETCH_OBJ: materialize a real stdClass and assign each column as a - // dynamic property, preserving numeric property names and binary data. - return $this->assignColumns(new stdClass(), $_count); + if ($_base == 12) { + // FETCH_KEY_PAIR: exactly two columns map to [col0 => col1]. P2-b: + // php-src raises this via pdo_raise_impl_error ("HY000"), which is + // errMode-aware (SILENT/WARNING return false instead of throwing), + // not a bare unconditional throw. + if ($_count != 2) { + $this->failCode("HY000", "PDO::FETCH_KEY_PAIR fetch mode requires the result set to contain exactly 2 columns."); + return false; + } + $_pk = $this->columnValue(0); + $_pv = $this->columnValue(1); + $_pair = []; + $_pair[$_pk] = $_pv; + return $_pair; + } + if ($_base == 5) { + // FETCH_OBJ: materialize a real stdClass and assign each column as a + // dynamic property, preserving numeric property names and binary data. + return $this->assignColumns(new stdClass(), $_count); + } + if ($_base == 8) { + // F-STMT-02: FETCH_CLASSTYPE (0x40000) means the class name is NOT the one + // configured on the statement — it is READ FROM COLUMN 0'S RUNTIME VALUE, row + // by row, so one result set can hydrate a different class per row + // (`SELECT type_col, … FROM t` with `type_col` holding 'Cat' or 'Dog'). + // php-src (pdo_stmt.c:805-829) does exactly three things this prelude used to + // do none of: it fetch_value()s column 0, it zend_lookup_class()es that string + // and FALLS BACK TO stdClass when no such class exists, and it then hydrates + // from column 1 onward — column 0 was CONSUMED as the type tag and must not + // also land in a property. The old code ignored the flag entirely, used the + // literal configured class, and assigned every column including 0. + // + // The internal PDO class classifier is lowered against the program's AOT metadata, + // so hydrateClassOrStd() can select stdClass before attempting dynamic allocation. + // + // The two arms are kept as two RETURNS rather than one reassigned local: the + // dynamic-new result is a Mixed, and rebinding that same local to a + // concrete `new stdClass()` would ask the checker to unify Mixed with + // Object(stdClass) in a slot that is about to be dynamic-property-written — + // exactly the shape of the known untyped-dynamic-prop corruption. Two + // straight-line returns give each object its own, single-typed local. + if (($mode & 0x40000) != 0) { + $_ctName = (string) $this->columnValue(0); + return $this->hydrateClassOrStd($_ctName, 1, $_count); + } + if ($this->fetchTarget !== null) { + $_classTarget = $this->fetchTarget; + return $this->hydrateClass($_classTarget, 0, $_count); + } + // No target configured: php-src's own default for a bare FETCH_CLASS is + // stdClass (pdo_stmt_setup_fetch_mode leaves stmt->fetch.cls.ce NULL, which + // do_fetch resolves to zend_standard_class_def). + return $this->assignColumns(new stdClass(), $_count); + } + if ($_base == 9) { + if ($this->fetchTarget !== null) { + return $this->assignColumns($this->fetchTarget, $_count); + } + // F-STMT-04: FETCH_INTO with NO object configured used to hand back a fresh, + // anonymous stdClass — a silent success that threw the caller's row into an + // object they never see. php-src raises HY000 "No fetch-into object specified." + // (pdo_stmt.c:864-871, via pdo_raise_impl_error, hence errmode-aware: it + // THROWS under ERRMODE_EXCEPTION and returns false under SILENT/WARNING). + // FETCH_INTO without a target is not a mode, it is a mistake — the target is + // the entire point of the mode. + $this->failCode("HY000", "No fetch-into object specified."); + return false; + } + if ($_base == 3) { + $_numRow = []; + for ($_i = 0; $_i < $_count; $_i++) { + $_numRow[$_i] = $this->columnValue($_i); + } + return $_numRow; + } + if ($_base == 2) { + $_assocRow = []; + for ($_i = 0; $_i < $_count; $_i++) { + $_name = $this->columnName($_i); + $_assocRow[$_name] = $this->columnValue($_i); + } + return $_assocRow; + } + if ($_base == 11) { + // P0-2 FETCH_NAMED: assoc-only, but when two or more result columns + // share a name, group their values into a numerically-indexed array + // under that one key instead of the last write silently winning + // (verified against real PHP: `SELECT 1 a, 2 a` => ["a" => [1, 2]], + // no numeric keys at all, and this grouping applies even when every + // duplicate's value is NULL). A column name seen once still stores a + // plain scalar, matching PHP exactly. + // + // Existence is tested by counting exact-name matches among the + // already-visited columns rather than `array_key_exists()`/`isset()`: + // the EIR backend does not support `array_key_exists()` on a Str key + // ("unsupported EIR backend feature: array_key_exists key PHP type + // Str", confirmed by compiling this branch), and `isset()` would + // wrongly treat a NULL-valued first occurrence as "not yet seen" + // (isset() is false for a key holding null), overwriting instead of + // grouping it. Column counts are always small, so the O(n^2) scan + // is cheap. + $_names = []; + for ($_i = 0; $_i < $_count; $_i++) { + $_names[$_i] = $this->columnName($_i); + } + $_namedRow = []; + for ($_i = 0; $_i < $_count; $_i++) { + $_name = $_names[$_i]; + $_value = $this->columnValue($_i); + $_priorCount = 0; + for ($_j = 0; $_j < $_i; $_j++) { + if ($_names[$_j] === $_name) { + $_priorCount = $_priorCount + 1; + } + } + if ($_priorCount == 0) { + $_namedRow[$_name] = $_value; + } elseif ($_priorCount == 1) { + $_namedRow[$_name] = [$_namedRow[$_name], $_value]; + } else { + $_existing = $_namedRow[$_name]; + $_existing[] = $_value; + $_namedRow[$_name] = $_existing; + } + } + return $_namedRow; + } + $_bothRow = []; + for ($_i = 0; $_i < $_count; $_i++) { + $_name = $this->columnName($_i); + $_value = $this->columnValue($_i); + $_bothRow[$_name] = $_value; + $_bothRow[$_i] = $_value; + } + return $_bothRow; + } + + public function fetchAll(int $mode = 0, mixed ...$args): array { + $_fetchAllArgCount = count($args); + $classOrObject = $_fetchAllArgCount > 0 ? $args[0] : null; + $ctorArgs = $_fetchAllArgCount > 1 ? $args[1] : null; + // + // NOTE that fetchAll() KEEPS its `mixed $classOrObject` second parameter while + // fetch() (F-STMT-01) loses its own: that is not an inconsistency, it is php-src. + // fetchAll's stub really does take the fetch-mode's extra arguments + // (`fetchAll(int $mode = PDO::FETCH_DEFAULT, mixed ...$args)`); fetch's really + // does not (its 2nd parameter is `int $cursorOrientation`). The two methods + // diverge in php-src exactly as they now diverge here. + if ($mode == 0) { + $mode = $this->fetchMode; + } + $_base = $mode & 0xFFFF; + // F-STMT-03: FETCH_LAZY is rejected HERE, and ONLY here. php-src's + // pdo_stmt_verify_mode takes a `fetch_all` flag and refuses FETCH_LAZY on that + // arm alone — fetchAll() is the one place real PHP forbids it, because a lazy + // PDORow is a view onto the CURRENT row and a list of them would all alias the + // last one. This prelude used to have the restriction exactly BACKWARDS: it + // rejected LAZY in fetch() (where php-src allows it) and accepted it here (where + // php-src does not). Message verbatim from php-src. + if ($_base == 1) { + throw new ValueError("PDOStatement::fetchAll(): Argument #1 (\$mode) cannot be PDO::FETCH_LAZY"); + } + if ($_base == 10) { + // FETCH_FUNC calls the supplied callback with one positional argument per + // column and collects its return values. The public variadic PHP signature is + // represented by this prelude's bounded extra slots, so argument #2 carries + // the callback. A divergent is_callable() guard narrows the Mixed slot to a + // callable; EIR then dispatches the narrowed Mixed value through the same + // descriptor/name selector used by call_user_func_array() elsewhere. + if (!is_callable($classOrObject)) { + throw new TypeError("PDOStatement::fetchAll(): Argument #2 must be a valid callback"); + } + $_fetchFunc = $classOrObject; + if (!$this->executed) { + return []; + } + $_funcRows = []; + while (true) { + $_frc = $this->stepCursor(); + if ($_frc < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + break; + } + if ($_frc == 0) { + break; + } + $_funcArgs = []; + $_funcCount = elephc_pdo_column_count($this->stmt); + for ($_fi = 0; $_fi < $_funcCount; $_fi++) { + $_funcArgs[] = $this->columnValue($_fi); + } + $_funcRows[] = call_user_func_array($_fetchFunc, $_funcArgs); + } + return $_funcRows; + } + if ($_base == 12) { + // FETCH_KEY_PAIR: aggregate the two-column result into [col0 => col1]. + // Stepped directly (not via fetch()) so the map is built exactly like + // FETCH_ASSOC, avoiding an intermediate single-entry return array. + if (!$this->executed) { + return []; + } + $_pairs = []; + while (true) { + $_krc = $this->stepCursor(); + if ($_krc < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + break; + } + if ($_krc == 0) { + break; + } + if (elephc_pdo_column_count($this->stmt) != 2) { + // P2-b: errMode-aware, matching fetch()'s own KEY_PAIR check + // above (SILENT/WARNING break out and return whatever pairs + // were already collected instead of throwing). + $this->failCode("HY000", "PDO::FETCH_KEY_PAIR fetch mode requires the result set to contain exactly 2 columns."); + break; + } + $_kk = $this->columnValue(0); + $_vv = $this->columnValue(1); + $_pairs[$_kk] = $_vv; + } + return $_pairs; + } + // The 2nd argument is applied to the STATEMENT before the row loop, never handed + // to fetch() — php-src does the same (PHP_METHOD(PDOStatement, fetchAll) writes + // stmt->fetch.column / stmt->fetch.cls.ce up front and then loops do_fetch), and + // since F-STMT-01 fetch() has no target parameter to hand it to anyway. + if ($_base == 7) { + // FETCH_COLUMN: `stmt->fetch.column = Z_LVAL(arg2)`. Without this, + // fetchAll(PDO::FETCH_COLUMN, $n) would silently return column 0 regardless + // of $n, since fetch()'s FETCH_COLUMN branch reads $this->fetchColumn. + if ($classOrObject !== null) { + $this->fetchColumn = (int) $classOrObject; + } elseif (($mode & 0x10000) != 0) { + // F-STMT-15: FETCH_COLUMN|FETCH_GROUP with NO explicit index defaults the + // VALUE column to 1, not 0 — php-src's fetchAll() spells this out + // (`stmt->fetch.column = arg2 ? … : (how & PDO_FETCH_GROUP ? 1 : 0)`), + // and it is what makes the classic idiom work: on `SELECT type, name`, + // `fetchAll(FETCH_GROUP|FETCH_COLUMN)` gives [type => [name, name, …]]. + // Column 0 is already spoken for as the grouping key, so defaulting the + // value to it too would return [type => [type, type, …]]. + $this->fetchColumn = 1; + } else { + // php-src: `stmt->fetch.column = arg2 ? Z_LVAL(arg2) : (how & + // PDO_FETCH_GROUP ? 1 : 0)` — the neither-branch of that ternary. + // Without this, a plain `fetchAll(PDO::FETCH_COLUMN)` (no index, no + // GROUP) would silently reuse whatever index a PRIOR + // `fetchAll(FETCH_COLUMN, $n)` call left on $this->fetchColumn instead + // of resetting to column 0. + $this->fetchColumn = 0; + } + } elseif (($_base == 8 || $_base == 9) && $classOrObject !== null) { + // FETCH_CLASS's class name / FETCH_INTO's object: `stmt->fetch.cls.ce`. + $this->fetchTarget = $classOrObject; + } + if ($_base == 8) { + if ($_fetchAllArgCount > 2) { + throw new ValueError("PDOStatement::fetchAll() expects at most 3 arguments for the fetch mode provided, " . (1 + $_fetchAllArgCount) . " given"); + } + if ($ctorArgs !== null && !is_array($ctorArgs)) { + throw new TypeError("PDOStatement::fetchAll(): Argument #3 must be of type array, " . $this->argValueTypeName($ctorArgs) . " given"); + } + if (is_array($ctorArgs)) { + $this->fetchCtorArgs = $this->copyConstructorArgs($ctorArgs); + } else { + $this->fetchCtorArgs = []; + } + $this->fetchPropsLate = ($mode & 0x100000) != 0; + } + // F-STMT-15: FETCH_GROUP (0x10000) and FETCH_UNIQUE (0x30000 — note it CONTAINS + // the GROUP bit, so it is tested first) reshape the whole result set around a key + // taken from column 0. They used to throw "not yet supported"; they are now real. + if (($mode & 0x10000) != 0) { + // Two combinations stay refused rather than faked, both because column 0 is + // already consumed as the grouping key and something else wants it too: + // - FETCH_CLASSTYPE also reads column 0 (as the class name). php-src resolves + // the collision by consuming TWO columns (key from 0, class from 1, props + // from 2), which is a shape no caller of this prelude has ever been able to + // ask for, so it is refused rather than invented. + // - FETCH_BOUND/FETCH_INTO/FETCH_NAMED under GROUP have no meaningful + // per-group row here (BOUND writes to bound columns this prelude does not + // support, INTO would hand every group the SAME object, NAMED's duplicate- + // name grouping is a second, orthogonal reshaping). Loud beats silently + // wrong: a caller gets an error naming the combination, not a plausible + // array of the wrong shape. + if (($mode & 0x40000) != 0) { + throw new PDOException("PDO::FETCH_CLASSTYPE is not supported with PDO::FETCH_GROUP or PDO::FETCH_UNIQUE"); + } + if ($_base != 2 && $_base != 3 && $_base != 4 && $_base != 5 && $_base != 7 && $_base != 8) { + throw new PDOException("PDO::FETCH_GROUP and PDO::FETCH_UNIQUE are not supported with this fetch mode"); + } + if (!$this->executed) { + return []; + } + // Both modes CONSUME COLUMN 0 as the key — it becomes the array key and is + // excluded from the row (groupRow() starts at column 1). They differ only in + // what a key maps to: + // FETCH_GROUP -> a LIST of every row that carried that key, in result order + // (php-src: add_next_index_zval into the group's array); + // FETCH_UNIQUE -> ONE row, LAST WRITE WINS (php-src: zend_symtable_update, a + // plain overwrite — it does not complain about a duplicate). + // + // FETCH_UNIQUE (0x30000) is a SUPERSET of FETCH_GROUP (0x10000), not a sibling + // of it, so "is this unique?" must test the whole 0x30000 mask — a bare + // `& 0x20000` would also accept a nonsense 0x20000-without-GROUP mode, and a + // bare `& 0x10000` (the caller's own dispatch test above) is true for BOTH. + // + // The key is CAST TO STRING first, exactly as php-src does + // (`convert_to_string`). groupKey() then applies PHP's array-key conversion: + // a canonical base-10 integer string that round-trips through int becomes an + // integer key, while leading-zero, plus-prefixed, overflow, and "-0" spellings + // remain strings. + // + // TWO TYPES OF THE SAME KEY are carried per row, on purpose. The split is what + // makes this both COMPILE and not CRASH, and every op below is one this backend + // is known to support: + // + // $_gkeyM (groupKey(), `mixed`) keys the OUTPUT array. A statically Str-typed + // key would make $_out a genuine AssocArray, and returning THAT from this + // method's `: array` needs an AssocArray -> Array(Mixed) conversion the EIR + // backend does not implement. A Mixed key keeps $_out an Array(Mixed) — the + // shape FETCH_KEY_PAIR above already relies on, with columnValue()'s Mixed + // return as its key. + // + // $_gkeyS (a plain `(string)`) keys the bucket map $_groups and the presence + // map $_present. A Str-keyed READ and STORE are both proven — FETCH_NAMED + // above does exactly that on $_namedRow. + // + // EXISTENCE IS TESTED BY A count() PROBE. isset()/array_key_exists() now work + // for these key shapes in isolation, but rewriting this mixed-row loop to a + // direct nested append reintroduces an array-representation mismatch at the + // control-flow join. The presence-map count delta avoids that compiler edge: + // storing a known key does not grow the map, while a new key grows it once. + // It is sound for any key value, null included, and remains O(n). + // + // FETCH_NAMED's alternative — counting prior matches by hand — is O(n^2), which + // is fine across a row's COLUMNS but not here, where n is the number of ROWS. + $_unique = ($mode & 0x30000) == 0x30000; + $_present = []; + $_groups = []; + $_order = []; + $_bn = 0; + $_out = []; + while (true) { + $_grc = $this->stepCursor(); + if ($_grc < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + break; + } + if ($_grc == 0) { + break; + } + $_gcount = elephc_pdo_column_count($this->stmt); + $_gkeyM = $this->groupKey(0); + $_gkeyS = (string) $_gkeyM; + $_grow = $this->groupRow($_base, $_gcount); + if ($_unique) { + // LAST WRITE WINS (php-src: zend_symtable_update, a plain overwrite that + // neither detects nor complains about a duplicate key), so no membership + // test is needed at all — the store IS the semantics. + $_out[$_gkeyM] = $_grow; + continue; + } + $_before = count($_present); + $_present[$_gkeyS] = 1; + if (count($_present) > $_before) { + // First sighting of this key: open its bucket, and remember the key (in + // its Mixed form, for the output store) at its first-seen position. + $_groups[$_gkeyS] = [$_grow]; + $_order[$_bn] = $_gkeyM; + $_bn = $_bn + 1; + } else { + // Append to the existing bucket. Detach it from the map FIRST: after the + // read the bucket has refcount 2 (the $_groups slot + $_bucket), so a bare + // push would COW-clone the whole bucket every row — O(n^2) over a large + // group (n = rows in the group). unset() drops the slot's reference to + // refcount 1, so `$_bucket[] = …` mutates in place (amortized O(1)); the + // same bucket is then reinserted under the same key. $_out is assembled + // from $_order (first-seen), never from $_groups iteration order, so the + // detach/reinsert cannot change the output; $_present is untouched, so the + // key stays "seen". This is the O(n^2)->O(n) fix, with no compiler change. + $_bucket = $_groups[$_gkeyS]; + unset($_groups[$_gkeyS]); + $_bucket[] = $_grow; + $_groups[$_gkeyS] = $_bucket; + } + } + if (!$_unique) { + // Assembled in FIRST-SEEN order, which is php-src's: a group is created when + // its key is first met and later rows are appended to it, so the groups come + // out in the order their keys first appeared in the result set. Nothing is + // ever read back out of $_out — it is written exactly once per distinct key. + for ($_gi = 0; $_gi < $_bn; $_gi++) { + $_gkOut = $_order[$_gi]; + $_out[$_gkOut] = $_groups[(string) $_gkOut]; + } + } + return $_out; + } + $_rows = []; + while (true) { + $_row = $this->fetch($mode); + if ($_row === false) { + break; + } + $_rows[] = $_row; + } + return $_rows; + } + + // F-STMT-02: assignColumnsFrom(), plus php-src's "class not found -> stdClass" arm + // (zend_lookup_class() failing, pdo_stmt.c:805-829). The null test remains defensive; + // hydrateClassOrStd() normally rejects unknown names with the AOT class classifier first. + // + // The object lives HERE, in a PARAMETER, rather than at the call site in a local, and + // that placement is load-bearing: routing a dynamic allocation result through a caller LOCAL + // MISCOMPILES — the object reaches the callee no longer an instance of its class and + // with none of its properties. Written INLINE as the argument (`new $_ctName()` straight + // into this call) it arrives sound, and a parameter then holds it safely. Verified both + // ways; the local form silently produced a property-less non-instance. + private function assignColumnsFromOrStd(mixed $object, int $start, int $count): mixed { + if ($object === null) { + return $this->assignColumnsFrom(new stdClass(), $start, $count); + } + return $this->assignColumnsFrom($object, $start, $count); + } + + // Keeps the dynamic-new result in a callee parameter until hydration is complete. Passing the + // object inline is required here: storing this Mixed result in the caller before the call loses + // its concrete runtime class identity in the current EIR local representation. + private function hydrateClassOrStdWithoutConstructor(mixed $object, string $class, int $start, int $count): mixed { + if ($object === null) { + return $this->assignColumnsFrom(new stdClass(), $start, $count); + } + $_object = $this->assignColumnsFrom($object, $start, $count); + if (__elephc_class_has_constructor($class)) { + call_user_func_array([$_object, "__construct"], $this->fetchCtorArgs); + } elseif (count($this->fetchCtorArgs) != 0) { + throw new Error("Class " . $class . " does not have a constructor, so you cannot pass any constructor arguments"); + } + return $_object; + } + + private function hydrateClassOrStd(string $class, int $start, int $count): mixed { + if (__elephc_pdo_statement_class_status($class) == 0) { + return $this->assignColumnsFrom(new stdClass(), $start, $count); + } + if ($this->fetchPropsLate) { + return $this->assignColumnsFromOrStd(new $class(...$this->fetchCtorArgs), $start, $count); + } + return $this->hydrateClassOrStdWithoutConstructor( + __elephc_new_without_constructor($class), + $class, + $start, + $count + ); + } + + // F-STMT-15: the FETCH_GROUP / FETCH_UNIQUE grouping key, taken from column 0 and CAST + // TO STRING exactly as php-src does (pdo_stmt.c do_fetch: `convert_to_string(&grp_val)` + // before the key ever reaches the hash table). + // + // Declared `: mixed` DELIBERATELY, not `: string` — see the call site. Returning the + // key as Mixed is what keeps fetchAll()'s $_out an Array(Mixed) instead of promoting it + // to a statically-typed AssocArray it could then not return through `: array`. + private function groupKey(int $index): mixed { + $_key = (string) $this->columnValue($index); + $_integerKey = (int) $_key; + if ((string) $_integerKey === $_key) { + return $_integerKey; + } + return $_key; + } + + // F-STMT-15: builds ONE grouped row — the part of the result that is NOT the key — + // in the shape the base fetch mode asks for, always starting from COLUMN 1 because + // column 0 was consumed as the grouping key by the caller. + // + // The numeric keys of FETCH_NUM/FETCH_BOTH are RE-INDEXED FROM 0, not left as the + // original column positions: php-src walks the row with two cursors — the column + // index `i` (which starts at 1 after the key was taken) and the output index `idx` + // (which starts at 0) — so the first column AFTER the key lands at [0]. A row that + // kept its original offsets would start at [1] and have no [0] at all. + private function groupRow(int $base, int $count): mixed { + if ($base == 7) { + // FETCH_COLUMN: the single configured value column (defaulted to 1 by + // fetchAll() when GROUP is set — see there), not a row at all. + return $this->columnValue($this->fetchColumn); + } + if ($base == 5) { + return $this->assignColumnsFrom(new stdClass(), 1, $count); + } + if ($base == 8) { + if ($this->fetchTarget !== null) { + $_gClass = $this->fetchTarget; + return $this->hydrateClass($_gClass, 1, $count); + } + return $this->assignColumnsFrom(new stdClass(), 1, $count); + } + if ($base == 3) { + $_gNum = []; + $_gIdx = 0; + for ($_i = 1; $_i < $count; $_i++) { + $_gNum[$_gIdx] = $this->columnValue($_i); + $_gIdx = $_gIdx + 1; + } + return $_gNum; + } + if ($base == 2) { + $_gAssoc = []; + for ($_i = 1; $_i < $count; $_i++) { + $_gName = $this->columnName($_i); + $_gAssoc[$_gName] = $this->columnValue($_i); + } + return $_gAssoc; + } + // FETCH_BOTH (4), the remaining accepted base — fetchAll()'s own guard has + // already rejected every mode that is not one of the six handled here. + $_gBoth = []; + $_gPos = 0; + for ($_i = 1; $_i < $count; $_i++) { + $_gBothName = $this->columnName($_i); + $_gBothVal = $this->columnValue($_i); + $_gBoth[$_gBothName] = $_gBothVal; + $_gBoth[$_gPos] = $_gBothVal; + $_gPos = $_gPos + 1; + } + return $_gBoth; + } + + public function fetchColumn(int $column = 0): mixed { + if (!$this->executed) { + return false; + } + $_rc = $this->stepCursor(); + if ($_rc < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + if ($_rc == 0) { + return false; + } + // P2-11: bounds-check against the row actually fetched (verified against + // real PHP: an out-of-range index on an EMPTY result set just returns + // `false` like any other no-more-rows call — the ValueError only fires + // once a row exists to check the index against). + if ($column < 0) { + throw new ValueError("Column index must be greater than or equal to 0"); + } + if ($column >= $this->columnCount()) { + throw new ValueError("Invalid column index"); + } + return $this->columnValue($column); + } + + public function closeCursor(): bool { + // Free the result set and require a re-execute before the next fetch, + // matching PHP: after closeCursor() a fetch on the forward-only cursor + // returns false until execute() runs again. + elephc_pdo_reset($this->stmt); + $this->executed = false; + // Defensive: a pending pre-step (see execute()'s comment) would + // otherwise reference a row that this reset just discarded. + // Practically unreachable today (fetch()'s `!executed` guard already + // blocks stepCursor() from running until the next execute() call + // overwrites it), but keeping the flag in lockstep with `executed` + // avoids relying on that as an invariant here. + $this->hasPendingStep = false; + return true; + } + + public function fetchObject(?string $class = "stdClass", array $constructorArgs = []): mixed { + if (!$this->executed) { + return false; + } + $_rc = $this->stepCursor(); + if ($_rc < 0) { + $this->fail(elephc_pdo_errmsg($this->conn)); + return false; + } + if ($_rc == 0) { + return false; + } + $_count = elephc_pdo_column_count($this->stmt); + if ($class === null || $class === "stdClass") { + return $this->assignColumns(new stdClass(), $_count); + } + $this->fetchCtorArgs = $this->copyConstructorArgs($constructorArgs); + $this->fetchPropsLate = false; + return $this->hydrateClass((string) $class, 0, $_count); + } + + public function rowCount(): int { + // The affected-row count captured at execute() time. Reliable for DML + // (INSERT/UPDATE/DELETE); for SELECT it is driver-dependent, exactly as + // in PHP. Snapshotting keeps it stable against later statements sharing + // the connection. + return $this->rowCount; + } + + public function columnCount(): int { + return elephc_pdo_column_count($this->stmt); + } + + public function getAttribute(int $name): mixed { + if (elephc_pdo_driver_name($this->conn) === "sqlsrv" + && ($name == 10 || ($name >= 1000 && $name <= 1009))) { + $_sqlsrvValue = elephc_pdo_sqlsrv_stmt_attribute($this->stmt, $name); + if ($_sqlsrvValue >= 0) { + return ($name == 1002 || $name == 1005 || $name == 1006 || $name == 1007 || $name == 1009) + ? ($_sqlsrvValue === 1) + : $_sqlsrvValue; + } + } + if ($name == 9 && (elephc_pdo_driver_name($this->conn) === "odbc" || elephc_pdo_driver_name($this->conn) === "informix" || elephc_pdo_driver_name($this->conn) === "ibm")) { + $_cursorName = elephc_pdo_odbc_stmt_cursor_name($this->stmt); + return $_cursorName === "" ? null : $_cursorName; + } + if ($name == 1001 && elephc_pdo_driver_name($this->conn) === "odbc") { + return elephc_pdo_odbc_stmt_assume_utf8($this->stmt) === 1; + } + if ($name == 9 && elephc_pdo_driver_name($this->conn) === "firebird") { + $_cursorName = elephc_pdo_firebird_stmt_cursor_name($this->stmt); + return $_cursorName === "" ? null : $_cursorName; + } + if ($name == 1001 && elephc_pdo_driver_name($this->conn) === "pgsql") { + $this->hasOperation = true; + $_memory = elephc_pdo_result_memory_size($this->stmt); + return $_memory < 0 ? null : $_memory; + } + // P2-16: Pdo\Sqlite::ATTR_READONLY_STATEMENT is a LIVE sqlite3_stmt_readonly() + // read rather than a stored value — it reflects the actual prepared + // statement, not a value the caller set. The bridge reports 0 for a + // non-SQLite statement, which reads back as false there too. + if ($name == 1001) { + return elephc_pdo_stmt_readonly($this->stmt) === 1; + } + if ($name == 1003 && elephc_pdo_driver_name($this->conn) === "sqlite") { + return elephc_pdo_stmt_busy($this->stmt) === 1; + } + if ($name == 1004 && elephc_pdo_driver_name($this->conn) === "sqlite") { + return elephc_pdo_stmt_explain_mode($this->stmt); + } + // P1-i: ATTR_EMULATE_PREPARES answers from the prepare()-time snapshot of + // the owning connection's stored value (see setEmulatePrepares()); real + // PHP answers this one from a live driver flag (`generic_stmt_attr_get`), + // but none of elephc's drivers ever emulates a prepare, so the + // snapshot is the closest honest analogue. + if ($name == 20) { + return $this->emulatePrepares; + } + // P1-i/P3: no driver in this bridge registers a statement attribute + // hook, so every other attribute mirrors php-src's IM001 "This driver + // doesn't support getting attributes" (pdo_raise_impl_error) — + // errMode-aware: EXCEPTION throws, WARNING/SILENT fall through and + // return `false` (verified against php-src's + // `PHP_METHOD(PDOStatement, getAttribute)`: the no-hook branch is + // `RETURN_FALSE`, not NULL). + $this->failCode("IM001", "This driver doesn't support getting attributes"); + return false; + } + + public function setAttribute(int $attribute, mixed $value): bool { + if (elephc_pdo_driver_name($this->conn) === "sqlsrv") { + if ($attribute == 1002 || $attribute == 10 || $attribute == 1003) { + $this->failCode("IMSSP", "The attribute may only be set when preparing a statement."); + return false; + } + if ($attribute == 1000 || $attribute == 1001 || $attribute == 1004 || $attribute == 1005 || $attribute == 1006 || $attribute == 1007 || $attribute == 1008 || $attribute == 1009) { + $_sqlsrvValue = ($attribute == 1005 || $attribute == 1006 || $attribute == 1007 || $attribute == 1009) + ? ($value ? 1 : 0) + : (int) $value; + if (elephc_pdo_sqlsrv_stmt_set_attribute($this->stmt, $attribute, $_sqlsrvValue) !== 1) { + $this->failCode("IMSSP", "An invalid statement attribute was designated."); + return false; + } + return true; + } + } + if ($attribute == 9 && (elephc_pdo_driver_name($this->conn) === "odbc" || elephc_pdo_driver_name($this->conn) === "informix" || elephc_pdo_driver_name($this->conn) === "ibm")) { + return elephc_pdo_odbc_stmt_set_cursor_name($this->stmt, (string) $value) === 1; + } + if ($attribute == 1001 && elephc_pdo_driver_name($this->conn) === "odbc") { + return elephc_pdo_odbc_stmt_set_assume_utf8($this->stmt, $value ? 1 : 0) === 1; + } + if ($attribute == 9 && elephc_pdo_driver_name($this->conn) === "firebird") { + $_cursorName = (string) $value; + if (strlen($_cursorName) > 31) { + throw new ValueError("Cursor name must not be longer than 31 bytes"); + } + return elephc_pdo_firebird_stmt_set_cursor_name($this->stmt, $_cursorName) === 1; + } + if ($attribute == 1004 && elephc_pdo_driver_name($this->conn) === "sqlite") { + if (!is_int($value)) { + throw new TypeError("explain mode must be of type int, " . $this->argValueTypeName($value) . " given"); + } + $_explainMode = (int) $value; + if ($_explainMode < 0 || $_explainMode > 2) { + throw new ValueError("explain mode must be one of the Pdo\\Sqlite::EXPLAIN_MODE_* constants"); + } + return elephc_pdo_stmt_set_explain_mode($this->stmt, $_explainMode) === 1; + } + // P1-i: no driver in this bridge registers a statement attribute hook, so + // every attribute mirrors php-src's IM001 "This driver doesn't support + // setting attributes" (pdo_raise_impl_error) instead of the previous + // unconditional accept-and-store. errMode-aware, like every other + // statement failure; always returns false regardless of mode. + // + // BOTH parameters are explicitly parked: no attribute is supported, so neither the + // name nor the value is ever read, and an unparked one is a compiler warning emitted + // against every program that so much as links this prelude. + $_unusedAttribute = $attribute; + $_unusedValue = $value; + $this->failCode("IM001", "This driver doesn't support setting attributes"); + return false; + } + + public function nextRowset(): bool { + // P2-c/P3: SQLite and PostgreSQL genuinely have no further-rowset concept + // here (pdo_sqlite/pdo_pgsql each materialize exactly one result set per + // prepared statement), so mirror php-src's IM001 "driver does not + // support multiple rowsets" (pdo_raise_impl_error, exact wording + // verified against php-src) instead of silently returning false — + // errMode-aware like every other statement failure. + // + // MySQL retains every protocol result set during execute(), including + // empty OK-packet sets between SELECT-like sets. DBLIB likewise exposes + // each `dbresults()` result (or skips empty ones when its driver option + // requests that). Advancing resets the row cursor and refreshes + // rowCount()/column metadata for the new set. + $_rowsetDriver = elephc_pdo_driver_name($this->conn); + if ($_rowsetDriver === "mysql" || $_rowsetDriver === "dblib" || $_rowsetDriver === "odbc" || $_rowsetDriver === "informix" || $_rowsetDriver === "ibm" || $_rowsetDriver === "sqlsrv" || $_rowsetDriver === "cubrid") { + if (elephc_pdo_next_rowset($this->stmt) !== 1) { + return false; + } + $this->hasPendingStep = false; + $this->pendingStep = 0; + $this->executed = true; + $this->hasOperation = true; + $this->rowCount = elephc_pdo_changes($this->conn); + return true; + } + $this->failCode("IM001", "driver does not support multiple rowsets"); + return false; + } + + public function getColumnMeta(int $column): array|bool { + // PDOStatement::getColumnMeta is assembled from the common PDO descriptor and + // the active driver's metadata. Returns false for an out-of-range column index. + // + // P2-h: also false when the statement hasn't been executed yet — there is + // no result set (or, for a non-SELECT statement, no columns) to describe. + // + // P2-k / F-PG-01 / F-PG-02: a `pgsql:` statement instead reports PostgreSQL's real + // per-column metadata, in FULL as of v26. `elephc_pdo_column_type_oid` returns the + // column's `PQftype` OID (0 for a non-pg statement or out-of-range index), + // threaded from the prepared statement's retained `postgres::types::Type`; a + // non-zero OID selects the pg branch below, which reports the server's native_type + // (`int4`/`bool`/`bytea`/… via `elephc_pdo_column_native_type`, i.e. + // `pg_type.typname`), the matching `pdo_type` (BOOL→PARAM_BOOL, + // {INT2,INT4,INT8}→PARAM_INT, {BYTEA,OID}→PARAM_LOB, else PARAM_STR — the + // exact switch in php-src's ext/pdo_pgsql/pgsql_statement.c), the `pgsql:oid` key, + // and now `len` (PQfsize), `precision` (PQfmod), `pgsql:table_oid` (PQftable), + // and the source table name resolved through `pg_class` when one exists. + // + // A `mysql:` statement gets OID 0 and is handled by the explicit MySQL branch + // below. SQLite then falls through to its runtime-storage-class metadata. + // + // P3: a negative column index throws a `ValueError` BEFORE the + // executed/range checks below, mirroring php-src's exact ordering and + // message wording (verified against php-src's + // `PHP_METHOD(PDOStatement, getColumnMeta)`: `zend_argument_value_error` + // fires from parameter validation, ahead of any driver dispatch or + // executed-state check) — only a column index `>=` the real column + // count still returns `false` (php-src only RETURN_FALSEs for that + // case, never for a negative one). + if ($column < 0) { + throw new ValueError("PDOStatement::getColumnMeta(): Argument #1 (\$column) must be greater than or equal to 0"); + } + if (!$this->executed) { + return false; + } + if ($column >= elephc_pdo_column_count($this->stmt)) { + return false; + } + $_oid = elephc_pdo_column_type_oid($this->stmt, $column); + if ($_oid > 0) { + // pgsql (P2-k): describe with the real PostgreSQL type. pdo_type + // mirrors php-src pdo_pgsql's OID switch exactly + // (ext/pdo_pgsql/pgsql_statement.c:690-706) — BOOLOID (16) is + // PARAM_BOOL (5); the integer family INT8/INT2/INT4 (20/21/23) is + // PARAM_INT (1); OIDOID (26) shares the PARAM_LOB (3) case with + // BYTEAOID (17) — `case OIDOID: case BYTEAOID:` is a literal pair in + // that switch, because an OID is a large-object handle to pdo_pgsql, + // not an integer value (F-PG-04: it was grouped with the ints here); + // and every other OID (text/varchar/numeric/timestamptz/json/…) is + // PARAM_STR (2). Raw integer literals here (not the PDO::PARAM_* + // constants) match the storage-class branch below. + $_pgType = 2; + if ($_oid == 16) { + $_pgType = 5; + } elseif ($_oid == 17 || $_oid == 26) { + $_pgType = 3; + } elseif ($_oid == 20 || $_oid == 21 || $_oid == 23) { + $_pgType = 1; + } + // F-PG-01/F-PG-02 (v26): the three remaining pg metadata fields, which used to + // be hardcoded 0 / omitted. + // + // `pgsql:table_oid` is emitted UNCONDITIONALLY, **0 included** — php-src's + // pgsql_stmt_get_column_meta adds the key on every column with no test at all, + // and 0 is InvalidOid, the server's OWN answer for a column that is not a plain + // table column (an expression, a literal, an aggregate). Suppressing the key on + // 0 would make `array_key_exists('pgsql:table_oid', $meta)` diverge from real + // PDO on exactly the columns where a caller is most likely to test it. + // + // `len` and `precision` are PQfsize() and PQfmod() STRAIGHT, and they are NOT + // what the names suggest: + // * len is the TYPE's byte width when it has a fixed one (int4 -> 4, + // timestamp -> 8, uuid -> 16) and **-1** for any VARLENA — text, varchar, + // numeric, bytea, json, every array type. A VARCHAR(20) reports len -1, + // NOT 20. + // * precision is the RAW atttypmod, undecoded. VARCHAR(20)'s declared 20 + // surfaces HERE, as 24 (20 + VARHDRSZ); NUMERIC(10,2) is 655366 + // (((10 << 16) | 2) + 4). + // Both are counter-intuitive and both are exactly what real PDO reports. + // Decoding atttypmod into a human-readable precision here would be a + // divergence dressed up as a courtesy — a caller who wants the real precision + // must decode the modifier, precisely as it would have to against real PDO. + $_pgMeta = [ + "name" => $this->columnName($column), + "native_type" => elephc_pdo_column_native_type($this->stmt, $column), + "pdo_type" => $_pgType, + "len" => elephc_pdo_column_len($this->stmt, $column), + "precision" => elephc_pdo_column_precision($this->stmt, $column), + "flags" => [], + "pgsql:oid" => $_oid, + "pgsql:table_oid" => elephc_pdo_column_table_oid($this->stmt, $column), + ]; + $_pgTable = elephc_pdo_column_table_name($this->stmt, $column); + if ($_pgTable !== "") { + $_pgMeta["table"] = $_pgTable; + } + return $_pgMeta; + } + $_driver = elephc_pdo_driver_name($this->conn); + if ($_driver === "cubrid") { + $_cubridFlags = elephc_pdo_column_flags($this->stmt, $column); + $_cubridUnique = ($_cubridFlags & 4) !== 0; + return [ + "type" => elephc_pdo_column_native_type($this->stmt, $column), + "name" => $this->columnName($column), + "table" => elephc_pdo_column_table_name($this->stmt, $column), + "def" => elephc_pdo_cubrid_column_default($this->stmt, $column), + "precision" => elephc_pdo_column_precision($this->stmt, $column), + "scale" => elephc_pdo_cubrid_column_scale($this->stmt, $column), + "not_null" => (($_cubridFlags & 1) !== 0) ? 1 : 0, + "auto_increment" => (($_cubridFlags & 2) !== 0) ? 1 : 0, + "unique_key" => $_cubridUnique ? 1 : 0, + "multiple_key" => $_cubridUnique ? 0 : 1, + "primary_key" => (($_cubridFlags & 8) !== 0) ? 1 : 0, + "foreign_key" => (($_cubridFlags & 16) !== 0) ? 1 : 0, + "reverse_index" => (($_cubridFlags & 32) !== 0) ? 1 : 0, + "reverse_unique" => (($_cubridFlags & 64) !== 0) ? 1 : 0, + ]; + } + if ($_driver === "firebird") { + return ["pdo_type" => elephc_pdo_firebird_column_pdo_type($this->stmt, $column)]; + } + if ($_driver === "odbc") { + return ["pdo_type" => 2]; + } + if ($_driver === "sqlsrv") { + if (elephc_pdo_sqlsrv_stmt_attribute($this->stmt, 1009) === 1) { + $_sqlsrvPairCount = elephc_pdo_sqlsrv_classification_pair_count($this->stmt, $column); + if ($_sqlsrvPairCount < 0) { + $this->fail(elephc_pdo_stmt_errmsg($this->stmt)); + return false; + } + $_sqlsrvDataClassification = []; + $_sqlsrvPairIndex = 0; + while ($_sqlsrvPairIndex < $_sqlsrvPairCount) { + $_sqlsrvProperty = [ + "Label" => [ + "name" => elephc_pdo_sqlsrv_classification_text($this->stmt, $column, $_sqlsrvPairIndex, 0), + "id" => elephc_pdo_sqlsrv_classification_text($this->stmt, $column, $_sqlsrvPairIndex, 1), + ], + "Information Type" => [ + "name" => elephc_pdo_sqlsrv_classification_text($this->stmt, $column, $_sqlsrvPairIndex, 2), + "id" => elephc_pdo_sqlsrv_classification_text($this->stmt, $column, $_sqlsrvPairIndex, 3), + ], + ]; + $_sqlsrvPairRank = elephc_pdo_sqlsrv_classification_pair_rank($this->stmt, $column, $_sqlsrvPairIndex); + if ($_sqlsrvPairRank >= 0) { + $_sqlsrvProperty["rank"] = $_sqlsrvPairRank; + } + $_sqlsrvDataClassification[] = $_sqlsrvProperty; + $_sqlsrvPairIndex = $_sqlsrvPairIndex + 1; + } + $_sqlsrvQueryRank = elephc_pdo_sqlsrv_classification_query_rank($this->stmt); + if ($_sqlsrvQueryRank >= 0) { + $_sqlsrvDataClassification["rank"] = $_sqlsrvQueryRank; + } + return [ + "flags" => ["Data Classification" => $_sqlsrvDataClassification], + "sqlsrv:decl_type" => elephc_pdo_column_native_type($this->stmt, $column), + "native_type" => "string", + "table" => elephc_pdo_column_table_name($this->stmt, $column), + "pdo_type" => 2, + "name" => $this->columnName($column), + "len" => elephc_pdo_column_len($this->stmt, $column), + "precision" => elephc_pdo_column_precision($this->stmt, $column), + ]; + } + return [ + "flags" => 0, + "sqlsrv:decl_type" => elephc_pdo_column_native_type($this->stmt, $column), + "native_type" => "string", + "table" => elephc_pdo_column_table_name($this->stmt, $column), + "pdo_type" => 2, + "name" => $this->columnName($column), + "len" => elephc_pdo_column_len($this->stmt, $column), + "precision" => elephc_pdo_column_precision($this->stmt, $column), + ]; + } + if ($_driver === "informix") { + $_informixFlags = []; + $_informixFlagBits = elephc_pdo_column_flags($this->stmt, $column); + $_informixFlags["not_null"] = ($_informixFlagBits & 1) !== 0; + $_informixFlags["unsigned"] = ($_informixFlagBits & 2) !== 0; + $_informixFlags["auto_increment"] = ($_informixFlagBits & 4) !== 0; + $_informixTable = elephc_pdo_column_table_name($this->stmt, $column); + if ($_informixTable !== "") { + return [ + "scale" => elephc_pdo_informix_column_scale($this->stmt, $column), + "table" => $_informixTable, + "native_type" => elephc_pdo_column_native_type($this->stmt, $column), + "flags" => $_informixFlags, + "pdo_type" => elephc_pdo_informix_column_pdo_type($this->stmt, $column), + "name" => $this->columnName($column), + "len" => elephc_pdo_column_len($this->stmt, $column), + "precision" => elephc_pdo_column_precision($this->stmt, $column), + ]; + } + return [ + "scale" => elephc_pdo_informix_column_scale($this->stmt, $column), + "native_type" => elephc_pdo_column_native_type($this->stmt, $column), + "flags" => $_informixFlags, + "pdo_type" => elephc_pdo_informix_column_pdo_type($this->stmt, $column), + "name" => $this->columnName($column), + "len" => elephc_pdo_column_len($this->stmt, $column), + "precision" => elephc_pdo_column_precision($this->stmt, $column), + ]; + } + if ($_driver === "ibm") { + $_ibmFlags = []; + $_ibmFlagBits = elephc_pdo_column_flags($this->stmt, $column); + $_ibmFlags["not_null"] = ($_ibmFlagBits & 1) !== 0; + $_ibmFlags["unsigned"] = ($_ibmFlagBits & 2) !== 0; + $_ibmFlags["auto_increment"] = ($_ibmFlagBits & 4) !== 0; + $_ibmTable = elephc_pdo_column_table_name($this->stmt, $column); + if ($_ibmTable !== "") { + return [ + "scale" => elephc_pdo_ibm_column_scale($this->stmt, $column), + "table" => $_ibmTable, + "native_type" => elephc_pdo_column_native_type($this->stmt, $column), + "flags" => $_ibmFlags, + "pdo_type" => elephc_pdo_ibm_column_pdo_type($this->stmt, $column), + "name" => $this->columnName($column), + "len" => elephc_pdo_column_len($this->stmt, $column), + "precision" => elephc_pdo_column_precision($this->stmt, $column), + ]; + } + return [ + "scale" => elephc_pdo_ibm_column_scale($this->stmt, $column), + "native_type" => elephc_pdo_column_native_type($this->stmt, $column), + "flags" => $_ibmFlags, + "pdo_type" => elephc_pdo_ibm_column_pdo_type($this->stmt, $column), + "name" => $this->columnName($column), + "len" => elephc_pdo_column_len($this->stmt, $column), + "precision" => elephc_pdo_column_precision($this->stmt, $column), + ]; + } + if ($_driver === "oci") { + $_ociFlags = []; + $_ociFlagBits = elephc_pdo_oci_column_flags($this->stmt, $column); + if (($_ociFlagBits & 1) !== 0) { + $_ociFlags[] = "nullable"; + } else { + $_ociFlags[] = "not_null"; + } + if (($_ociFlagBits & 4) !== 0) { + $_ociFlags[] = "blob"; + } + $_ociType = elephc_pdo_column_native_type($this->stmt, $column); + return [ + "oci:decl_type" => $_ociType, + "native_type" => $_ociType, + "pdo_type" => elephc_pdo_oci_column_pdo_type($this->stmt, $column), + "scale" => elephc_pdo_oci_column_scale($this->stmt, $column), + "flags" => $_ociFlags, + ]; + } + if ($_driver === "dblib") { + $_dblibNativeId = elephc_pdo_dblib_column_native_type_id($this->stmt, $column); + $_dblibPdoType = 2; + if ($_dblibNativeId == 48 || $_dblibNativeId == 50 + || $_dblibNativeId == 52 || $_dblibNativeId == 56) { + $_dblibPdoType = 1; + } + return [ + "max_length" => elephc_pdo_column_len($this->stmt, $column), + "precision" => elephc_pdo_column_precision($this->stmt, $column), + "scale" => elephc_pdo_dblib_column_scale($this->stmt, $column), + "column_source" => elephc_pdo_dblib_column_source($this->stmt, $column), + "native_type" => elephc_pdo_column_native_type($this->stmt, $column), + "native_type_id" => $_dblibNativeId, + "native_usertype_id" => elephc_pdo_dblib_column_user_type_id($this->stmt, $column), + "pdo_type" => $_dblibPdoType, + ]; + } + if ($_driver === "mysql") { + $_myNative = elephc_pdo_column_native_type($this->stmt, $column); + $_myType = 2; + if ($_myNative === "BIT" || $_myNative === "YEAR" || $_myNative === "TINY" + || $_myNative === "SHORT" || $_myNative === "INT24" || $_myNative === "LONG" + || $_myNative === "LONGLONG") { + $_myType = 1; + } + $_myFlags = []; + $_myFlagBits = elephc_pdo_column_flags($this->stmt, $column); + if (($_myFlagBits & 1) !== 0) { + $_myFlags[] = "not_null"; + } + if (($_myFlagBits & 2) !== 0) { + $_myFlags[] = "primary_key"; + } + if (($_myFlagBits & 8) !== 0) { + $_myFlags[] = "multiple_key"; + } + if (($_myFlagBits & 4) !== 0) { + $_myFlags[] = "unique_key"; + } + if (($_myFlagBits & 16) !== 0) { + $_myFlags[] = "blob"; + } + // mysqlnd omits native_type for an unknown wire type rather than inventing + // a storage-class fallback. The binary column packet carries no default + // value, so `mysql:def` is likewise omitted when unavailable. + if ($_myNative === "") { + return [ + "pdo_type" => $_myType, + "flags" => $_myFlags, + "table" => elephc_pdo_column_table_name($this->stmt, $column), + "name" => $this->columnName($column), + "len" => elephc_pdo_column_len($this->stmt, $column), + "precision" => elephc_pdo_column_precision($this->stmt, $column), + ]; + } + return [ + "native_type" => $_myNative, + "pdo_type" => $_myType, + "flags" => $_myFlags, + "table" => elephc_pdo_column_table_name($this->stmt, $column), + "name" => $this->columnName($column), + "len" => elephc_pdo_column_len($this->stmt, $column), + "precision" => elephc_pdo_column_precision($this->stmt, $column), + ]; + } + + $_type = elephc_pdo_column_type($this->stmt, $column); + $_native = "null"; + $_pdoType = 0; + $_flags = []; + if ($_type == 1) { + $_native = "integer"; + $_pdoType = 1; + } elseif ($_type == 2) { + $_native = "double"; + $_pdoType = 2; + } elseif ($_type == 3) { + $_native = "string"; + $_pdoType = 2; + } elseif ($_type == 4) { + $_native = "string"; + $_pdoType = 2; + $_flags[] = "blob"; + } + $_meta = [ + "name" => $this->columnName($column), + "native_type" => $_native, + "pdo_type" => $_pdoType, + "len" => -1, + "precision" => 0, + "flags" => $_flags, + ]; + // P1-8: the column's DECLARED type (sqlite3_column_decltype) is a SEPARATE + // "sqlite:decl_type" key — it must never overwrite native_type above. Empty + // for an expression column with no declared type (or a non-SQLite driver, + // where the bridge always reports an empty decltype), matching PHP's + // omitting the key entirely in that case. + $_decltype = elephc_pdo_column_decltype($this->stmt, $column); + if ($_decltype !== "") { + $_meta["sqlite:decl_type"] = $_decltype; + } + $_table = elephc_pdo_column_table_name($this->stmt, $column); + if ($_table !== "") { + $_meta["table"] = $_table; + } + return $_meta; + } + + public function debugDumpParams(): ?bool { + // F-STMT-12: full php-src line shapes (pdo_stmt.c:1963-2020) — the SQL line, the + // parameter count, then ONE block per bound parameter: + // + // SQL: [] + // Params: + // Key: Name: [] :name (named) / Key: Position #: + // paramno= + // name=[] ":name" + // is_param=1 + // param_type= + // + // Note the two spaces after "Params:" and the QUOTED name on the `name=` line — + // both are php-src's own spacing/quoting (`"paramno=" ZEND_LONG_FMT "\nname=[%zd] + // \"%.*s\"\nis_param=%d\nparam_type=%d\n"`), not a typo here. + // + // The arrays are append-only for reference ownership, but php-src stores a hash: + // only the last bind for a positional slot or named placeholder is visible here. + // Named parameters retain paramno=-1 until execute-time normalization. + echo "SQL: [" . strlen($this->queryString) . "] " . $this->queryString . "\n"; + $_sentSql = elephc_pdo_stmt_sent_sql($this->stmt); + if ($_sentSql !== "") { + echo "Sent SQL: [" . strlen($_sentSql) . "] " . $_sentSql . "\n"; + } + $_recordCount = count($this->boundValues); + $_pcount = 0; + for ($_i = 0; $_i < $_recordCount; $_i++) { + $_shadowed = false; + for ($_j = $_i + 1; $_j < $_recordCount; $_j++) { + $_bothPositional = $this->boundNames[$_i] === "" && $this->boundNames[$_j] === ""; + $_bothNamed = $this->boundNames[$_i] !== "" && $this->boundNames[$_j] !== ""; + if (($_bothPositional || $_bothNamed) && $this->boundParams[$_i] == $this->boundParams[$_j]) { + $_shadowed = true; + break; + } + } + if (!$_shadowed) { + $_pcount = $_pcount + 1; + } + } + echo "Params: " . $_pcount . "\n"; + for ($_i = 0; $_i < $_recordCount; $_i++) { + $_shadowed = false; + for ($_j = $_i + 1; $_j < $_recordCount; $_j++) { + $_bothPositional = $this->boundNames[$_i] === "" && $this->boundNames[$_j] === ""; + $_bothNamed = $this->boundNames[$_i] !== "" && $this->boundNames[$_j] !== ""; + if (($_bothPositional || $_bothNamed) && $this->boundParams[$_i] == $this->boundParams[$_j]) { + $_shadowed = true; + break; + } + } + if ($_shadowed) { + continue; + } + $_dname = (string) $this->boundNames[$_i]; + // php's paramno is 0-based; the recorded slot is the driver's 1-based index. + $_dno = ((int) $this->boundParams[$_i]) - 1; + if ($_dname !== "") { + $_normalized = false; + foreach ($this->boundNormalizedIndexes as $_normalizedIndex) { + if ($_normalizedIndex == $_i) { + $_normalized = true; + break; + } + } + if (!$_normalized) { + $_dno = -1; + } + } + $_dtype = (int) $this->boundPhpTypes[$_i]; + $_dlen = strlen($_dname); + if ($_dname === "") { + echo "Key: Position #" . $_dno . ":\n"; + } else { + echo "Key: Name: [" . $_dlen . "] " . $_dname . "\n"; + } + echo "paramno=" . $_dno . "\n"; + echo "name=[" . $_dlen . "] \"" . $_dname . "\"\n"; + // is_param is 1 for every entry of bound_params; php's 0 case is a bound COLUMN + // (bindColumn), which lives in a different hash and is not dumped here. + echo "is_param=1\n"; + echo "param_type=" . $_dtype . "\n"; + } + // Always null (never false): php returns false only when it cannot open + // php://output, which has no elephc equivalent. + return null; + } + + public function getIterator(): \Iterator { + return new __ElephcPDOStatementIterator($this); + } + + public function __destruct() { + // Finalize the prepared statement when the PDOStatement is collected. The + // bridge ignores an unknown/already-finalized handle, so this is safe even + // when the owning PDO connection was closed first (its close() already + // finalized this statement). + elephc_pdo_finalize($this->stmt); + // Drop the explicit connection root while the object is still fully + // initialized. This makes the final PDO owner observable immediately and + // avoids relying on post-destructor property sweeping for a nullable object + // slot whose runtime representation is boxed Mixed. + $this->owner = null; + } + + // P2-17: mirrors \PDO::__clone() — PHP marks PDOStatement uncloneable too. A + // shallow clone would produce a second owner of `$this->stmt`; whichever copy is + // destructed first finalizes the handle out from under the survivor. + public function __clone(): void { + throw new Error("Trying to clone an uncloneable object of class " . get_class($this)); + } + + // F-CORE-15: mirrors \PDO::__serialize()/__sleep() (see the long rationale there) — + // php-src marks PDOStatement `/** @not-serializable */` too, and elephc's + // property-walking serialize() would otherwise emit this object's private `$stmt` + // and `$conn` bridge handles into the blob, yielding a zombie statement on + // unserialize(). Same php-src message shape, same plain `Exception` class, same + // get_class($this) so the reported name is the object's real class. + public function __serialize(): array { + throw new Exception("Serialization of '" . get_class($this) . "' is not allowed"); + } + + public function __sleep(): array { + throw new Exception("Serialization of '" . get_class($this) . "' is not allowed"); + } +} + +/// Prefixed userland adapter for php-src's internal PDO statement iterator. +final class __ElephcPDOStatementIterator implements Iterator { + private PDOStatement $statement; + private mixed $row; + private int $position; + + public function __construct(PDOStatement $statement) { + $this->statement = $statement; + $this->row = null; + $this->position = 0; + } + + public function rewind(): void { + $this->row = $this->statement->fetch(); + $this->position = 0; + } + + public function valid(): bool { + return $this->row !== false; + } + + public function current(): mixed { + return $this->row; + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->row = $this->statement->fetch(); + $this->position = $this->position + 1; + } +} + +// PHP 8.4 driver-specific PDO subclasses. They are returned by the DSN-dispatching +// `PDO::connect()` factory (defined above) and can also be constructed directly; +// each inherits the full base PDO connection surface (constructor, exec/query/ +// prepare, transactions, quoting) from \PDO, and adds its driver-specific +// constants and driver methods. Callback methods use rooted callable descriptors +// and shared C-to-PHP adapters; connection-backed methods delegate to the PDO bridge. +// +// The classes are declared in a BLOCK-form namespace: a statement-form +// `namespace Pdo;` would apply to every statement that follows it, and because +// this prelude is prepended ahead of user code that would silently re-namespace +// the entire user program. The block keeps the `Pdo\` scope contained, leaving +// the appended user code in the global namespace. `extends \PDO` is +// fully-qualified so it binds to the global prelude PDO regardless of scope. +// Builtins called from a method body here are `\`-qualified because an unqualified +// call inside the `Pdo` namespace does not fall back to the global function on +// every name-resolution path. +// -- elephc PHP >= 8.4 namespaced PDO drivers begin -- +namespace Pdo { + // -- elephc optional PDO_DBLIB class begin -- + class Dblib extends \PDO { + const ATTR_CONNECTION_TIMEOUT = 1000; + const ATTR_QUERY_TIMEOUT = 1001; + const ATTR_STRINGIFY_UNIQUEIDENTIFIER = 1002; + const ATTR_VERSION = 1003; + const ATTR_TDS_VERSION = 1004; + const ATTR_SKIP_EMPTY_ROWSETS = 1005; + const ATTR_DATETIME_CONVERT = 1006; + + public function __construct(string $dsn, ?string $username = null, #[\SensitiveParameter] ?string $password = null, ?array $options = null) { + $_operation = get_class($this) . "::__construct"; + $_dblibDsn = self::resolveDsnAlias($dsn, $_operation); + $_dblibDsn = self::resolveDsnUri($_dblibDsn, $_operation); + $this->checkDriverSubclassDsn($_dblibDsn, "Pdo\\Dblib", "dblib"); + parent::__construct($_dblibDsn, $username, $password, $options); + } + } + // -- elephc optional PDO_DBLIB class end -- + + // -- elephc optional PDO_FIREBIRD class begin -- + class Firebird extends \PDO { + const ATTR_DATE_FORMAT = 1000; + const ATTR_TIME_FORMAT = 1001; + const ATTR_TIMESTAMP_FORMAT = 1002; + const TRANSACTION_ISOLATION_LEVEL = 1003; + const READ_COMMITTED = 1004; + const REPEATABLE_READ = 1005; + const SERIALIZABLE = 1006; + const WRITABLE_TRANSACTION = 1007; + + public function __construct(string $dsn, ?string $username = null, #[\SensitiveParameter] ?string $password = null, ?array $options = null) { + $_operation = get_class($this) . "::__construct"; + $_firebirdDsn = self::resolveDsnAlias($dsn, $_operation); + $_firebirdDsn = self::resolveDsnUri($_firebirdDsn, $_operation); + $this->checkDriverSubclassDsn($_firebirdDsn, "Pdo\\Firebird", "firebird"); + parent::__construct($_firebirdDsn, $username, $password, $options); } - if ($mode == 8) { - if ($classOrObject !== null) { - return $this->assignColumns(new $classOrObject(), $_count); + + public static function getApiVersion(): int { + return 40; + } + } + // -- elephc optional PDO_FIREBIRD class end -- + + // -- elephc optional PDO_ODBC class begin -- + class Odbc extends \PDO { + const ATTR_USE_CURSOR_LIBRARY = 1000; + const ATTR_ASSUME_UTF8 = 1001; + const SQL_USE_IF_NEEDED = 0; + const SQL_USE_ODBC = 1; + const SQL_USE_DRIVER = 2; + + public function __construct(string $dsn, ?string $username = null, #[\SensitiveParameter] ?string $password = null, ?array $options = null) { + $_operation = get_class($this) . "::__construct"; + $_odbcDsn = self::resolveDsnAlias($dsn, $_operation); + $_odbcDsn = self::resolveDsnUri($_odbcDsn, $_operation); + $this->checkDriverSubclassDsn($_odbcDsn, "Pdo\\Odbc", "odbc"); + parent::__construct($_odbcDsn, $username, $password, $options); + } + } + // -- elephc optional PDO_ODBC class end -- + + // -- elephc optional PDO_IBM class begin -- + class Ibm extends \PDO { + const ATTR_INFO_USERID = 1281; + const ATTR_INFO_ACCTSTR = 1282; + const ATTR_INFO_APPLNAME = 1283; + const ATTR_INFO_WRKSTNNAME = 1284; + const ATTR_USE_TRUSTED_CONTEXT = 2561; + const ATTR_TRUSTED_CONTEXT_USERID = 2562; + const ATTR_TRUSTED_CONTEXT_PASSWORD = 2563; + + public function __construct(string $dsn, ?string $username = null, #[\SensitiveParameter] ?string $password = null, ?array $options = null) { + $_operation = get_class($this) . "::__construct"; + $_ibmDsn = self::resolveDsnAlias($dsn, $_operation); + $_ibmDsn = self::resolveDsnUri($_ibmDsn, $_operation); + $this->checkDriverSubclassDsn($_ibmDsn, "Pdo\\Ibm", "ibm"); + parent::__construct($_ibmDsn, $username, $password, $options); + } + } + // -- elephc optional PDO_IBM class end -- + + class Sqlite extends \PDO { + // SQLite driver-specific constants (ext/pdo_sqlite). ATTR_* start at + // PDO_ATTR_DRIVER_SPECIFIC (1000); OPEN_* mirror the SQLite C open flags; + // DETERMINISTIC is the SQLITE_DETERMINISTIC function flag. + const DETERMINISTIC = 2048; + const OPEN_READONLY = 1; + const OPEN_READWRITE = 2; + const OPEN_CREATE = 4; + const ATTR_OPEN_FLAGS = 1000; + const ATTR_READONLY_STATEMENT = 1001; + const ATTR_EXTENDED_RESULT_CODES = 1002; + // 8.5-READINESS: prelude_source_for_version() inserts the busy/explain/ + // transaction attributes, mode/authorizer constants, and setAuthorizer() + // only for PHP 8.5 and later. The baseline PHP 8.4 template intentionally ends + // at ATTR_EXTENDED_RESULT_CODES. + + // Roots the collation / user-function callbacks registered on this + // connection. SQLite keeps a raw C pointer to each callback's compiled-PHP + // descriptor for the connection's lifetime, so the descriptor must stay + // reachable from PHP; this array is that GC root. + // Dedicated authorizer root. This is deliberately untyped and seeded with + // a closure: elephc then gives the property callable storage, whose assignment + // path retains replacement closure descriptors. A Mixed property or an array + // element only retains the boxed container today, allowing the descriptor + // backing a replaced callback to be recycled before SQLite calls it. + private $authorizerCallback; + + public function __construct(string $dsn, ?string $username = null, #[\SensitiveParameter] ?string $password = null, ?array $options = null) { + // F-CORE-01/F-CORE-11: resolve an indirect `uri:` DSN FIRST (php-src resolves + // it before it compares the DSN's driver against the called scope), then reject + // a DSN belonging to another driver BEFORE any connection is attempted. The + // resolved DSN is what goes up to \PDO, so the file is read exactly once — + // resolveDsnUri() is a no-op on an already-resolved DSN. + $_operation = get_class($this) . "::__construct"; + $_sqliteDsn = self::resolveDsnAlias($dsn, $_operation); + $_sqliteDsn = self::resolveDsnUri($_sqliteDsn, $_operation); + $this->checkDriverSubclassDsn($_sqliteDsn, "Pdo\\Sqlite", "sqlite"); + // Forward to \PDO to open the connection, then initialise the callback + // root (an uninitialised typed array property is not implicitly []). + parent::__construct($_sqliteDsn, $username, $password, $options); + $this->authorizerCallback = function() { return 0; }; + } + + public function loadExtension(string $name): void { + // Loads a SQLite extension library by path (its entry point is + // auto-derived, as PHP's loadExtension does), throwing on failure. + // Extension loading runs native code from the named library, so it + // weakens the standalone-binary guarantee — use only trusted extensions. + // + // F-SQLT-05: an EMPTY name is rejected during argument validation, ahead of + // any driver dispatch — php-src's pdo_sqlite.c:80-87 is + // `if (ZSTR_LEN(extension) == 0) { zend_argument_must_not_be_empty_error(1); + // RETURN_THROWS(); }`, whose ValueError reads "…(): Argument #1 ($name) must + // not be empty". elephc used to hand "" straight to sqlite3_load_extension and + // surface its failure as the generic PDOException below, which is both the + // wrong exception class and the wrong stage. + if ($name === "") { + throw new \ValueError("Pdo\\Sqlite::loadExtension(): Argument #1 (\$name) must not be empty"); } - if ($this->fetchTarget !== null) { - $_classTarget = $this->fetchTarget; - return $this->assignColumns(new $_classTarget(), $_count); + if (\elephc_pdo_load_extension($this->connectionId(), $name) !== 1) { + throw new \PDOException("Failed to load SQLite extension: " . $name); } - return $this->assignColumns(new stdClass(), $_count); } - if ($mode == 9) { - if ($classOrObject !== null) { - return $this->assignColumns($classOrObject, $_count); + + public function openBlob(string $table, string $column, int $rowid, ?string $dbname = "main", int $flags = 1): mixed { + // The compiler-owned wrapper keeps an independent seek cursor and applies + // every writable patch immediately through sqlite3_blob_write. SQLite's + // incremental BLOB contract fixes the size at open time, so an extending + // write fails while reads, seeks, embedded NULs, and in-place writes match + // the native PDO stream. + $_db = ($dbname === null) ? "main" : $dbname; + return \__ElephcPDOSqliteBlobStream::create($this->connectionId(), $table, $column, $rowid, $_db, $flags); + } + + public function createCollation(string $name, mixed $callback): bool { + // Registers a custom collation `$name` backed by a compiled-PHP + // comparator `$callback($a, $b): int` (returning <0, 0, >0). The callable + // is decomposed here into its descriptor pointer and the shared codegen + // collation adapter address, so the bridge extern receives two plain + // `ptr` args and never a `callable`. The callback is rooted in + // $this->udfCallbacks first because SQLite keeps a C pointer to its + // descriptor for the connection's lifetime. The key is namespaced so a + // same-named collation and scalar function do not evict each other's GC + // root. __elephc_normalize_callable converts every supported PHP callable + // form to the descriptor representation consumed by the adapter. + if (!\is_callable($callback)) { + throw new \TypeError("Pdo\\Sqlite::createCollation(): Argument #2 (\$callback) must be a valid callback"); } - if ($this->fetchTarget !== null) { - return $this->assignColumns($this->fetchTarget, $_count); + $_normalized = \__elephc_normalize_callable($callback); + $_descriptor = \__elephc_callable_ptr($_normalized); + $_adapter = \__elephc_pdo_adapter_addr(0); + if (\elephc_pdo_create_collation($this->connectionId(), $name, $_descriptor, $_adapter) !== 1) { + return false; } - return $this->assignColumns(new stdClass(), $_count); + $this->pdoUdfCallbacks["collation:" . \strtolower($name)] = $_normalized; + return true; } - if ($mode == 3) { - $_numRow = []; - for ($_i = 0; $_i < $_count; $_i++) { - $_numRow[$_i] = $this->columnValue($_i); + + public function createFunction(string $function_name, mixed $callback, int $num_args = -1, int $flags = 0): bool { + // Registers a scalar SQL function `$function_name` backed by a compiled-PHP + // `$callback(...$args): mixed` invoked once per row. Like createCollation, + // the callable is decomposed here into its descriptor pointer and the shared + // codegen scalar adapter address, so the bridge extern receives two plain + // `ptr` args and never a `callable`. The callback is rooted in + // $this->udfCallbacks (under a function-namespaced key) first because SQLite + // keeps a C pointer to its descriptor for the connection's lifetime. + // $num_args is the declared arity (-1 = variadic); $flags carries + // self::DETERMINISTIC. Callable normalization accepts closures, names, + // callable arrays, invokable objects, and first-class descriptors. + // Parameter names match the PHP stub + // (`createFunction(string $function_name, callable $callback, int $num_args = -1, int $flags = 0)`) + // so named-argument calls resolve; the extern call below uses positions, + // so the rename is otherwise behavior-neutral. + if (!\is_callable($callback)) { + throw new \TypeError("Pdo\\Sqlite::createFunction(): Argument #2 (\$callback) must be a valid callback"); } - return $_numRow; + $_normalized = \__elephc_normalize_callable($callback); + $_descriptor = \__elephc_callable_ptr($_normalized); + $_adapter = \__elephc_pdo_adapter_addr(1); + if (\elephc_pdo_create_function($this->connectionId(), $function_name, $num_args, $flags, $_descriptor, $_adapter) !== 1) { + return false; + } + $this->pdoUdfCallbacks["function:" . \strtolower($function_name) . ":" . $num_args . ":scalar"] = $_normalized; + return true; } - if ($mode == 2) { - $_assocRow = []; - for ($_i = 0; $_i < $_count; $_i++) { - $_name = elephc_pdo_column_name($this->stmt, $_i); - $_assocRow[$_name] = $this->columnValue($_i); + + public function createAggregate(string $name, mixed $step, mixed $finalize, int $numArgs = -1): bool { + // Registers an aggregate SQL function `$name` backed by a compiled-PHP + // step + finalize pair: `$step($context, $rownumber, ...$values): mixed` + // runs once per row (returning the new accumulator, null-seeded on the + // first row) and `$finalize($context, $rownumber): mixed` produces the + // group result. Each callable is decomposed into its descriptor pointer + // and the shared codegen adapter address (kinds 2 and 3), so the bridge + // extern receives four plain `ptr` args and never a `callable`. Both + // callables are rooted in $this->udfCallbacks (under distinct keys so + // neither evicts the other's GC root) because SQLite keeps a C pointer to + // each descriptor for the connection's lifetime. Both callables pass + // through the same complete normalization path as scalar functions. + if (!\is_callable($step) || !\is_callable($finalize)) { + throw new \TypeError("Pdo\\Sqlite::createAggregate(): step and finalize must be valid callbacks"); } - return $_assocRow; + $_normalizedStep = \__elephc_normalize_callable($step); + $_normalizedFinal = \__elephc_normalize_callable($finalize); + $_stepDesc = \__elephc_callable_ptr($_normalizedStep); + $_stepAdapter = \__elephc_pdo_adapter_addr(2); + $_finalDesc = \__elephc_callable_ptr($_normalizedFinal); + $_finalAdapter = \__elephc_pdo_adapter_addr(3); + if (\elephc_pdo_create_aggregate($this->connectionId(), $name, $numArgs, $_stepDesc, $_stepAdapter, $_finalDesc, $_finalAdapter) !== 1) { + return false; + } + $_rootKey = "function:" . \strtolower($name) . ":" . $numArgs; + $this->pdoUdfCallbacks[$_rootKey . ":step"] = $_normalizedStep; + $this->pdoUdfCallbacks[$_rootKey . ":final"] = $_normalizedFinal; + return true; } - $_bothRow = []; - for ($_i = 0; $_i < $_count; $_i++) { - $_name = elephc_pdo_column_name($this->stmt, $_i); - $_value = $this->columnValue($_i); - $_bothRow[$_name] = $_value; - $_bothRow[$_i] = $_value; + + // -- elephc PHP >= 8.5 SQLite setAuthorizer insertion -- + } + + class Mysql extends \PDO { + // MySQL/MariaDB driver-specific attribute constants (ext/pdo_mysql, mysqlnd + // build — the PHP default). Values start at PDO_ATTR_DRIVER_SPECIFIC (1000). + // The libmysqlclient-only ATTR_MAX_BUFFER_SIZE / ATTR_READ_DEFAULT_* are + // intentionally omitted (absent under mysqlnd, and their presence would shift + // every value from ATTR_COMPRESS upward). + const ATTR_USE_BUFFERED_QUERY = 1000; + const ATTR_LOCAL_INFILE = 1001; + // P1-9: honored by PDO::__construct's constructor-options + // loop, which threads the raw SQL string through to the bridge's connect + // path (my.rs::MyConn::open -> OptsBuilder::init). The other options below + // are routed individually or rejected explicitly when the Rust client has + // no equivalent security control. + const ATTR_INIT_COMMAND = 1002; + const ATTR_COMPRESS = 1003; + const ATTR_DIRECT_QUERY = 1004; + const ATTR_FOUND_ROWS = 1005; + const ATTR_IGNORE_SPACE = 1006; + const ATTR_SSL_KEY = 1007; + const ATTR_SSL_CERT = 1008; + const ATTR_SSL_CA = 1009; + const ATTR_SSL_CAPATH = 1010; + const ATTR_SSL_CIPHER = 1011; + const ATTR_SERVER_PUBLIC_KEY = 1012; + const ATTR_MULTI_STATEMENTS = 1013; + const ATTR_SSL_VERIFY_SERVER_CERT = 1014; + const ATTR_LOCAL_INFILE_DIRECTORY = 1015; + + public function __construct(string $dsn, ?string $username = null, #[\SensitiveParameter] ?string $password = null, ?array $options = null) { + // F-CORE-01: this class had NO constructor at all, so `new Pdo\Mysql("sqlite:…")` + // inherited \PDO's and cheerfully opened a SQLite database behind a Pdo\Mysql + // object. The override exists solely to run the driver guard (and the `uri:` + // resolution it depends on) before any connection is attempted; it adds no + // MySQL-specific state of its own. See \PDO::checkDriverSubclassDsn(). + $_operation = get_class($this) . "::__construct"; + $_mysqlDsn = self::resolveDsnAlias($dsn, $_operation); + $_mysqlDsn = self::resolveDsnUri($_mysqlDsn, $_operation); + $this->checkDriverSubclassDsn($_mysqlDsn, "Pdo\\Mysql", "mysql"); + parent::__construct($_mysqlDsn, $username, $password, $options); + } + + public function getWarningCount(): int { + // The number of warnings raised by the last statement executed on this + // connection (MySQL/MariaDB `@@warning_count`). + return \elephc_pdo_warning_count($this->connectionId()); } - return $_bothRow; } - public function fetchAll(int $mode = 0, mixed $classOrObject = null): array { - if ($mode == 0) { - $mode = $this->fetchMode; + class Pgsql extends \PDO { + // PostgreSQL driver-specific constants (ext/pdo_pgsql). ATTR_* start at + // PDO_ATTR_DRIVER_SPECIFIC (1000); TRANSACTION_* mirror libpq's PQTRANS_* + // connection-transaction-status enum. + const ATTR_DISABLE_PREPARES = 1000; + const ATTR_RESULT_MEMORY_SIZE = 1001; + const TRANSACTION_IDLE = 0; + const TRANSACTION_ACTIVE = 1; + const TRANSACTION_INTRANS = 2; + const TRANSACTION_INERROR = 3; + const TRANSACTION_UNKNOWN = 4; + + // Connection-owned callback root. A concrete closure (rather than a + // nullable callable property) keeps compiled callable dispatch precise. + private $noticeCallback; + + public function __construct(string $dsn, ?string $username = null, #[\SensitiveParameter] ?string $password = null, ?array $options = null) { + // F-CORE-01/F-CORE-11: resolve an indirect `uri:` DSN, then reject a DSN + // belonging to another driver, both BEFORE any connection is attempted — see + // \PDO::checkDriverSubclassDsn(). + $_operation = get_class($this) . "::__construct"; + $_pgsqlDsn = self::resolveDsnAlias($dsn, $_operation); + $_pgsqlDsn = self::resolveDsnUri($_pgsqlDsn, $_operation); + $this->checkDriverSubclassDsn($_pgsqlDsn, "Pdo\\Pgsql", "pgsql"); + // Forward to \PDO to open the connection. The base connection object owns + // a virtual drain hook so prepared statements can dispatch here too. + parent::__construct($_pgsqlDsn, $username, $password, $options); + $this->noticeCallback = function($_message) {}; } - $_rows = []; - while (true) { - $_row = $this->fetch($mode, $classOrObject); - if ($_row === false) { - break; + + public function setNoticeCallback(?callable $callback): void { + // Registers a callback invoked with the text of each PostgreSQL server + // NOTICE. Passing null unregisters delivery by restoring the no-op callback. + // The Pdo\Pgsql object owns the callback; the base class only declares + // the virtual hook used by PDOStatement's PDO-typed owner reference. + if ($callback === null) { + $this->noticeCallback = function($_message) {}; + return; } - $_rows[] = $_row; + callable $_typedNoticeCallback = $callback; + $this->noticeCallback = $_typedNoticeCallback; } - return $_rows; - } - public function fetchColumn(int $column = 0): mixed { - $_rc = elephc_pdo_step($this->stmt); - if ($_rc < 0) { - $this->fail(elephc_pdo_errmsg($this->conn)); - return false; + protected function __elephcDrainPgsqlNotices(): void { + $_cb = $this->noticeCallback; + while (true) { + $_msg = \elephc_pdo_get_notice($this->connectionId()); + if ($_msg === "") { + break; + } + $_cb($_msg); + } } - if ($_rc == 0) { - return false; + + public function exec(string $statement): int|bool { + // Runs the statement through the base driver, then drains + dispatches any + // server NOTICE it raised (e.g. a DO block / function using RAISE NOTICE). + $_result = parent::exec($statement); + $this->__elephcDrainPgsqlNotices(); + return $_result; } - return $this->columnValue($column); - } - public function rowCount(): int { - // The affected-row count captured at execute() time. Reliable for DML - // (INSERT/UPDATE/DELETE); for SELECT it is driver-dependent, exactly as - // in PHP. Snapshotting keeps it stable against later statements sharing - // the connection. - return $this->rowCount; - } + public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): \PDOStatement|bool { + // As exec(), but for a row-returning statement. `\PDOStatement` is + // fully-qualified because this override lives inside `namespace Pdo`, where + // a bare `PDOStatement` would resolve to the non-existent `Pdo\PDOStatement`. + // Signature mirrors the widened base PDO::query() (P0-6) so overriding + // stays arity-compatible; the extra args are simply forwarded. + $_result = parent::query($query, $fetchMode, ...$fetchModeArgs); + $this->__elephcDrainPgsqlNotices(); + return $_result; + } - public function columnCount(): int { - return elephc_pdo_column_count($this->stmt); - } + public function escapeIdentifier(string $input): string { + // PostgreSQL identifier quoting (PQescapeIdentifier semantics): double any + // interior double-quote and wrap the whole identifier in double-quotes. A + // pure string transform with no server round-trip, so it is safe to call + // on any Pdo\Pgsql instance. (Divergence: PHP rejects an embedded NUL with + // a ValueError; that pathological case is not guarded here.) + $_doubled = \str_replace("\"", "\"\"", $input); + return "\"" . $_doubled . "\""; + } - // Iterator: `foreach ($stmt as $key => $row)` walks the result set forward - // using the statement's current fetch mode, with sequential integer keys — - // matching PHP's PDOStatement Traversable behavior. The cursor is - // forward-only, so rewind() only fetches the first row (it cannot seek back - // to an already-consumed row). - public function rewind(): void { - $this->iterRow = $this->fetch($this->fetchMode); - $this->iterKey = 0; - } + public function getPid(): int { + // The PostgreSQL backend process id serving this connection + // (`pg_backend_pid()`). + return \elephc_pdo_backend_pid($this->connectionId()); + } - public function valid(): bool { - return $this->iterRow !== false; - } + public function lobCreate(): string|bool { + // Creates an empty large object and returns its OID as a numeric string, + // or false on error. libpq's large-object API requires an explicit + // transaction, which is enforced before the bridge call. + if (!$this->inTransaction()) { + return false; + } + $_oid = \elephc_pdo_lob_create($this->connectionId()); + return $_oid === "" ? false : $_oid; + } - public function current(): mixed { - return $this->iterRow; - } + public function lobUnlink(string $oid): bool { + // Deletes the large object with the given OID. + if (!$this->inTransaction()) { + return false; + } + return \elephc_pdo_lob_unlink($this->connectionId(), $oid) === 1; + } - public function key(): mixed { - return $this->iterKey; - } + public function lobOpen(string $oid, string $mode = "rb"): mixed { + // A mode containing `+` or `w` is writable, matching php-src's mode test. + // The wrapper is seekable, extends with zero-filled gaps, and writes each + // patch back synchronously while the owning transaction remains active. + return \__ElephcPDOPgsqlLobStream::create($this, $this->connectionId(), $oid, $mode); + } - public function next(): void { - $this->iterRow = $this->fetch($this->fetchMode); - $this->iterKey = $this->iterKey + 1; - } + private function copyOptions(string $separator, string $nullAs): string { + // PostgreSQL COPY text format defaults DELIMITER to a tab and NULL to + // "\N", so only emit a WITH clause when the caller overrides them. A tab + // delimiter must use the E'\t' escape-string form. + // + // F-PG-05: the separator is TRUNCATED TO ITS FIRST BYTE. PostgreSQL's COPY + // grammar admits only a single one-byte delimiter, and all four of php-src's + // COPY builders dereference exactly one byte of the argument — + // `(pg_delim_len ? *pg_delim : '\t')` (pgsql_driver.c:654, 773, 882, 973) — + // silently dropping the rest. This prelude interpolated the WHOLE string, so + // `copyFromArray(…, "::")` emitted `DELIMITER '::'` and the SERVER rejected the + // statement, where real PHP quietly copies with `:`. Truncating is not + // "accepting garbage": it is the documented, observable behavior of the + // function being reimplemented, and the alternative (a hard error) would fail + // code that works on real PDO. + // + // An EMPTY separator falls back to the tab default, which is php-src's own + // `pg_delim_len ? … : '\t'` ternary — the length test, not the byte. + $_sep = $separator === "" ? "\t" : \substr($separator, 0, 1); + if ($_sep === "\t" && $nullAs === "\\N") { + return ""; + } + $_delim = $_sep === "\t" ? "E'\\t'" : "'" . $_sep . "'"; + $_null = "'" . \str_replace("'", "''", $nullAs) . "'"; + return " WITH (DELIMITER " . $_delim . ", NULL " . $_null . ")"; + } - public function __destruct() { - // Finalize the prepared statement when the PDOStatement is collected. The - // bridge ignores an unknown/already-finalized handle, so this is safe even - // when the owning PDO connection was closed first (its close() already - // finalized this statement). - elephc_pdo_finalize($this->stmt); + private function copyTarget(string $tableName, ?string $fields): string { + // The `table [(col, …)]` prefix shared by the COPY builders. + if ($fields !== null) { + return $tableName . " (" . $fields . ")"; + } + return $tableName; + } + + public function copyFromArray(string $tableName, array $rows, string $separator = "\t", string $nullAs = "\\N", ?string $fields = null): bool { + // Each element of $rows is a full line (its fields already joined by + // $separator); join them into the newline-terminated stream COPY FROM + // STDIN consumes. On error the connection's errorInfo is set by the bridge. + $_data = \implode("\n", $rows) . "\n"; + $_sql = "COPY " . $this->copyTarget($tableName, $fields) . " FROM STDIN" + . $this->copyOptions($separator, $nullAs); + return \elephc_pdo_copy_in($this->connectionId(), $_sql, $_data) >= 0; + } + + public function copyFromFile(string $tableName, string $filename, string $separator = "\t", string $nullAs = "\\N", ?string $fields = null): bool { + // Reads the client-side file and streams it as COPY FROM STDIN, matching + // PHP's client-side file read. + $_data = \file_get_contents($filename); + if ($_data === false) { + return false; + } + $_sql = "COPY " . $this->copyTarget($tableName, $fields) . " FROM STDIN" + . $this->copyOptions($separator, $nullAs); + // Cast to string: the checker does not narrow $_data out of string|false + // after the `=== false` guard above, and copy_in's $data param is Str. + return \elephc_pdo_copy_in($this->connectionId(), $_sql, (string) $_data) >= 0; + } + + public function copyToArray(string $tableName, string $separator = "\t", string $nullAs = "\\N", ?string $fields = null): array|false { + // Returns the table's rows, one array element per row (each keeping its + // trailing newline, as PHP's copyToArray does). P2-i: copy_out() returns + // "" for BOTH an empty COPY and a transport error, so an empty result is + // no longer enough to tell them apart; the bridge always resets errcode + // to 0 on success and sets it non-zero via fail() on error (checked + // immediately after the call, so nothing else can have touched it in + // between), which is exactly the distinction the stub's `array|false` + // return type needs. A genuinely empty table still returns []. + $_sql = "COPY " . $this->copyTarget($tableName, $fields) . " TO STDOUT" + . $this->copyOptions($separator, $nullAs); + $_raw = \elephc_pdo_copy_out($this->connectionId(), $_sql); + if ($_raw === "") { + if (\elephc_pdo_errcode($this->connectionId()) != 0) { + return false; + } + return []; + } + $_lines = \explode("\n", \rtrim($_raw, "\n")); + $_out = []; + foreach ($_lines as $_line) { + $_out[] = $_line . "\n"; + } + return $_out; + } + + public function copyToFile(string $tableName, string $filename, string $separator = "\t", string $nullAs = "\\N", ?string $fields = null): bool { + // Writes the table's COPY TO STDOUT output to the client-side file. + // P2-i: the same empty-vs-error ambiguity as copyToArray() applies here — + // without the errcode check, a failed COPY would still write an empty + // file and report success. + $_sql = "COPY " . $this->copyTarget($tableName, $fields) . " TO STDOUT" + . $this->copyOptions($separator, $nullAs); + $_raw = \elephc_pdo_copy_out($this->connectionId(), $_sql); + if ($_raw === "" && \elephc_pdo_errcode($this->connectionId()) != 0) { + return false; + } + return \file_put_contents($filename, $_raw) !== false; + } + + public function getNotify(int $fetchMode = 0, int $timeoutMilliseconds = 0): mixed { + // Polls for a pending LISTEN/NOTIFY notification, or false if none + // arrived within the timeout. + // + // P2-5: $fetchMode == PDO::FETCH_ASSOC (2) shapes the result as + // ["message"=>channel, "pid"=>pid, "payload"=>payload] (php-src + // pgsql_driver.c's assoc keys — "message" holds the channel name); + // anything else keeps the numerically-indexed [0=>channel, 1=>pid, + // 2=>payload] (FETCH_NUM) shape. The declared return type is `mixed` + // rather than PHP's own `array` (already a pre-existing divergence here, + // documented in docs/php/pdo.md): elephc's EIR array backend cannot unify + // a string-keyed array literal with a positionally-keyed one as a single + // `array`-typed return, but boxing through `mixed` (the same technique + // `PDOStatement::fetch()` already relies on for its own FETCH_ASSOC vs + // FETCH_NUM branches) sidesteps that and lets both shapes coexist. + $_raw = \elephc_pdo_get_notify($this->connectionId(), $timeoutMilliseconds); + if ($_raw === "") { + return false; + } + // Split only the two framing tabs. The payload is the untouched remainder, + // so an arbitrary PostgreSQL NOTIFY payload containing tabs stays byte-exact. + $_sep1 = (int) \strpos($_raw, "\t"); + $_channel = \substr($_raw, 0, $_sep1); + $_rest = \substr($_raw, $_sep1 + 1); + $_sep2 = (int) \strpos($_rest, "\t"); + $_pid = (int) \substr($_rest, 0, $_sep2); + $_payload = \substr($_rest, $_sep2 + 1); + if ($fetchMode == 2) { + return ["message" => $_channel, "pid" => $_pid, "payload" => $_payload]; + } + return [$_channel, $_pid, $_payload]; + } } } +// -- elephc PHP >= 8.4 namespaced PDO drivers end -- "#; /// Prepends the PDO prelude statements to `program` when it references PDO, so the @@ -590,11 +6794,713 @@ class PDOStatement implements Iterator { /// always injected, making it available even when auto-detection would not see /// the usage. pub fn inject_if_used(program: Program, force: bool) -> Program { + inject_if_used_for_version(program, force, PhpVersion::default()) +} + +/// Prepends the PDO prelude generated for an explicit PHP compatibility version. +/// +/// PHP 8.5 renumbered every high fetch-mode flag into the low byte. Generating the +/// constants and all decoding masks from the same version selection prevents a source +/// program compiled for 8.4 from being interpreted with 8.5 flag semantics. +pub fn inject_if_used_for_version( + program: Program, + force: bool, + php_version: PhpVersion, +) -> Program { if !force && !detect::program_uses_pdo(&program) { return program; } - let tokens = crate::lexer::tokenize(PDO_PRELUDE_SRC).expect("PDO prelude must tokenize"); - let mut combined = crate::parser::parse_internal(&tokens).expect("PDO prelude must parse"); + let mut combined = parsed_prelude_for_version(php_version); combined.extend(program); combined } + +/// Returns an independent clone of the parsed PDO prelude for one effective profile. +/// +/// The compiler mutates every injected AST in later passes, so the cache stores an +/// immutable template and clones it per compilation. This avoids repeatedly tokenizing +/// and parsing the same 7,000-line prelude while preserving compilation isolation. +fn parsed_prelude_for_version(php_version: PhpVersion) -> Program { + let key = (php_version, optional_driver_mask()); + let cache = PARSED_PRELUDE_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + cache + .entry(key) + .or_insert_with(|| { + let source = prelude_source_for_version(php_version); + let tokens = crate::lexer::tokenize(source.as_ref()).expect("PDO prelude must tokenize"); + crate::parser::parse_internal(&tokens).expect("PDO prelude must parse") + }) + .clone() +} + +/// Encodes the optional-driver environment that changes the generated prelude source. +fn optional_driver_mask() -> u8 { + [ + (cfg!(feature = "pdo-cubrid"), "ELEPHC_PDO_CUBRID"), + (cfg!(feature = "pdo-dblib"), "ELEPHC_PDO_DBLIB"), + (cfg!(feature = "pdo-firebird"), "ELEPHC_PDO_FIREBIRD"), + (cfg!(feature = "pdo-odbc"), "ELEPHC_PDO_ODBC"), + (cfg!(feature = "pdo-ibm"), "ELEPHC_PDO_IBM"), + (cfg!(feature = "pdo-sqlsrv"), "ELEPHC_PDO_SQLSRV"), + (cfg!(feature = "pdo-oci"), "ELEPHC_PDO_OCI"), + ] + .iter() + .enumerate() + .fold(0, |mask, (index, (feature_enabled, env_name))| { + if *feature_enabled || std::env::var_os(env_name).is_some() { + mask | (1 << index) + } else { + mask + } + }) +} + +/// Returns the PDO prelude source with version-specific fetch constants and decoders. +fn prelude_source_for_version(php_version: PhpVersion) -> Cow<'static, str> { + if php_version == PhpVersion::Php84 { + let mut source = PDO_PRELUDE_SRC.to_owned(); + remove_version_block( + &mut source, + " // -- elephc PHP >= 8.5 PDO pgsql simple streaming begin --", + " // -- elephc PHP >= 8.5 PDO pgsql simple streaming end --", + ); + configure_optional_drivers(&mut source, php_version); + return Cow::Owned(source); + } + + if php_version < PhpVersion::Php84 { + let mut source = PDO_PRELUDE_SRC.to_owned(); + remove_version_block( + &mut source, + " // -- elephc PHP >= 8.5 PDO pgsql simple streaming begin --", + " // -- elephc PHP >= 8.5 PDO pgsql simple streaming end --", + ); + remove_version_block( + &mut source, + " // -- elephc PHP >= 8.4 PDO::connect begin --", + " // -- elephc PHP >= 8.4 PDO::connect end --", + ); + remove_version_block( + &mut source, + "// -- elephc PHP >= 8.4 namespaced PDO drivers begin --", + "// -- elephc PHP >= 8.4 namespaced PDO drivers end --", + ); + if php_version == PhpVersion::Php80 { + // PDOStatement::$queryString and PDORow::$queryString became public + // properties in PHP 8.1. Keep private storage for the prelude's own SQL + // bookkeeping under 8.0 without exposing the later user-facing surface. + source = source.replace( + "public readonly string $queryString;", + "private string $queryString;", + ); + } + if php_version < PhpVersion::Php82 { + source = source.replace("#[\\SensitiveParameter] ", ""); + } + configure_optional_drivers(&mut source, php_version); + return Cow::Owned(source); + } + + let mut source = PDO_PRELUDE_SRC + .replace("const FETCH_GROUP = 0x10000;", "const FETCH_GROUP = 0x20;") + .replace("const FETCH_UNIQUE = 0x30000;", "const FETCH_UNIQUE = 0x40;") + .replace("const FETCH_CLASSTYPE = 0x40000;", "const FETCH_CLASSTYPE = 0x80;") + .replace("const FETCH_SERIALIZE = 0x80000;", "const FETCH_SERIALIZE = 0x200;") + .replace("const FETCH_PROPS_LATE = 0x100000;", "const FETCH_PROPS_LATE = 0x100;") + .replace( + " const TRANSACTION_IDLE = 0;", + " #[\\Deprecated(\"as it has no effect\")]\n const TRANSACTION_IDLE = 0;", + ) + .replace( + " const TRANSACTION_ACTIVE = 1;", + " #[\\Deprecated(\"as it has no effect\")]\n const TRANSACTION_ACTIVE = 1;", + ) + .replace( + " const TRANSACTION_INTRANS = 2;", + " #[\\Deprecated(\"as it has no effect\")]\n const TRANSACTION_INTRANS = 2;", + ) + .replace( + " const TRANSACTION_INERROR = 3;", + " #[\\Deprecated(\"as it has no effect\")]\n const TRANSACTION_INERROR = 3;", + ) + .replace( + " const TRANSACTION_UNKNOWN = 4;", + " #[\\Deprecated(\"as it has no effect\")]\n const TRANSACTION_UNKNOWN = 4;", + ) + .replace( + "const ATTR_EXTENDED_RESULT_CODES = 1002;\n // 8.5-READINESS:", + "const ATTR_EXTENDED_RESULT_CODES = 1002;\n const ATTR_BUSY_STATEMENT = 1003;\n const ATTR_EXPLAIN_STATEMENT = 1004;\n const ATTR_TRANSACTION_MODE = 1005;\n const TRANSACTION_MODE_DEFERRED = 0;\n const TRANSACTION_MODE_IMMEDIATE = 1;\n const TRANSACTION_MODE_EXCLUSIVE = 2;\n const EXPLAIN_MODE_PREPARED = 0;\n const EXPLAIN_MODE_EXPLAIN = 1;\n const EXPLAIN_MODE_EXPLAIN_QUERY_PLAN = 2;\n const OK = 0;\n const DENY = 1;\n const IGNORE = 2;\n // 8.5-READINESS:", + ) + .replace("$_base = $mode & 0xFFFF;", "$_base = $mode & 0xF;") + .replace("($mode & 0x40000) != 0", "($mode & 0x80) != 0") + .replace("($mode & 0x40000) == 0", "($mode & 0x80) == 0") + .replace("($mode & 0x100000) != 0", "($mode & 0x100) != 0") + .replace( + "elseif (($mode & 0x10000) != 0)", + "elseif ((($mode & 0x20) != 0) || (($mode & 0x40) != 0))", + ) + .replace( + "if (($mode & 0x10000) != 0)", + "if ((($mode & 0x20) != 0) || (($mode & 0x40) != 0))", + ) + .replace( + "$_unique = ($mode & 0x30000) == 0x30000;", + "$_unique = ($mode & 0x40) != 0;", + ) + .replace( + " // -- elephc PHP >= 8.5 setFetchMode class flags --", + " if (($mode & (0x80 | 0x100 | 0x200)) != 0 && $_base != 8) {\n throw new ValueError(\"PDOStatement::setFetchMode(): Argument #1 (\\$mode) cannot use PDO::FETCH_CLASSTYPE, PDO::FETCH_PROPS_LATE, or PDO::FETCH_SERIALIZE fetch flags with a fetch mode other than PDO::FETCH_CLASS\");\n }", + ) + .replace( + "if (($mode & 0x80) != 0 && $_base != 8) {\n throw new ValueError('PDOStatement::fetch(): Argument #1 ($mode) must use PDO::FETCH_CLASSTYPE with PDO::FETCH_CLASS');", + "if (($mode & (0x80 | 0x100 | 0x200)) != 0 && $_base != 8) {\n throw new ValueError('PDOStatement::fetch(): Argument #1 ($mode) cannot use PDO::FETCH_CLASSTYPE, PDO::FETCH_PROPS_LATE, or PDO::FETCH_SERIALIZE fetch flags with a fetch mode other than PDO::FETCH_CLASS');", + ) + .replace( + "throw new ValueError(\"PDOStatement::fetchAll(): Argument #1 (\\$mode) cannot be PDO::FETCH_LAZY\");\n }\n if ($_base == 10) {", + "throw new ValueError(\"PDOStatement::fetchAll(): Argument #1 (\\$mode) PDO::FETCH_LAZY cannot be used with PDOStatement::fetchAll()\");\n }\n if ($_base == 9) {\n throw new ValueError(\"PDOStatement::fetchAll(): Argument #1 (\\$mode) PDO::FETCH_INTO cannot be used with PDOStatement::fetchAll()\");\n }\n if ($_base == 10) {", + ) + .replace( + " // -- elephc PHP >= 8.5 SQLite setAuthorizer insertion --", + " public function setAuthorizer(?callable $callback): void {\n // PHP 8.5+: null removes the native registration before its rooted\n // descriptor is released. A callable reuses the scalar adapter because\n // SQLite's authorizer ABI is five scalar arguments plus an integer result.\n if ($callback === null) {\n \\elephc_pdo_clear_authorizer($this->connectionId());\n $this->authorizerCallback = function() { return 0; };\n return;\n }\n if (!\\is_callable($callback)) {\n throw new \\TypeError(\"Pdo\\\\Sqlite::setAuthorizer(): Argument #1 (\\$callback) must be a valid callback or null\");\n }\n $_normalized = \\__elephc_normalize_callable($callback);\n $_descriptor = \\__elephc_callable_ptr($_normalized);\n $_adapter = \\__elephc_pdo_adapter_addr(1);\n if (\\elephc_pdo_set_authorizer($this->connectionId(), $_descriptor, $_adapter) !== 1) {\n throw new \\PDOException(\"Failed to register SQLite authorizer\");\n }\n $this->authorizerCallback = $_normalized;\n }", + ); + if php_version >= PhpVersion::Php86 { + source = source.replace( + "elephc_pdo_release($this->conn, 0);", + "elephc_pdo_release($this->conn, 1);", + ); + } + configure_optional_drivers(&mut source, php_version); + Cow::Owned(source) +} + +/// Applies build-profile and PHP-version gates for optional system-client drivers. +fn configure_optional_drivers(source: &mut String, php_version: PhpVersion) { + let cubrid_enabled = cfg!(feature = "pdo-cubrid") + || std::env::var_os("ELEPHC_PDO_CUBRID").is_some(); + if !cubrid_enabled { + remove_version_block( + source, + " // -- elephc optional PDO_CUBRID constants begin --", + " // -- elephc optional PDO_CUBRID constants end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_CUBRID method begin --", + " // -- elephc optional PDO_CUBRID method end --", + ); + } + + let dblib_enabled = cfg!(feature = "pdo-dblib") + || std::env::var_os("ELEPHC_PDO_DBLIB").is_some(); + if !dblib_enabled { + remove_version_block( + source, + " // -- elephc optional PDO_DBLIB aliases begin --", + " // -- elephc optional PDO_DBLIB aliases end --", + ); + if php_version >= PhpVersion::Php84 { + remove_version_block( + source, + " // -- elephc optional PDO_DBLIB connect dispatch begin --", + " // -- elephc optional PDO_DBLIB connect dispatch end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_DBLIB connect construction begin --", + " // -- elephc optional PDO_DBLIB connect construction end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_DBLIB class begin --", + " // -- elephc optional PDO_DBLIB class end --", + ); + } + } else if php_version >= PhpVersion::Php85 { + for (legacy_name, namespaced_name) in [ + ("DBLIB_ATTR_CONNECTION_TIMEOUT", "ATTR_CONNECTION_TIMEOUT"), + ("DBLIB_ATTR_QUERY_TIMEOUT", "ATTR_QUERY_TIMEOUT"), + ("DBLIB_ATTR_STRINGIFY_UNIQUEIDENTIFIER", "ATTR_STRINGIFY_UNIQUEIDENTIFIER"), + ("DBLIB_ATTR_VERSION", "ATTR_VERSION"), + ("DBLIB_ATTR_TDS_VERSION", "ATTR_TDS_VERSION"), + ("DBLIB_ATTR_SKIP_EMPTY_ROWSETS", "ATTR_SKIP_EMPTY_ROWSETS"), + ("DBLIB_ATTR_DATETIME_CONVERT", "ATTR_DATETIME_CONVERT"), + ] { + assert!( + source.contains(&format!(" const {legacy_name} =")), + "missing PDO_DBLIB alias {legacy_name}" + ); + *source = source.replacen( + &format!(" const {legacy_name} ="), + &format!(" #[\\Deprecated(\"use Pdo\\\\Dblib::{namespaced_name} instead\")]\n const {legacy_name} ="), + 1, + ); + } + } + + let firebird_enabled = cfg!(feature = "pdo-firebird") + || std::env::var_os("ELEPHC_PDO_FIREBIRD").is_some(); + if !firebird_enabled { + remove_version_block( + source, + " // -- elephc optional PDO_FIREBIRD aliases begin --", + " // -- elephc optional PDO_FIREBIRD aliases end --", + ); + if php_version >= PhpVersion::Php84 { + remove_version_block( + source, + " // -- elephc optional PDO_FIREBIRD connect dispatch begin --", + " // -- elephc optional PDO_FIREBIRD connect dispatch end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_FIREBIRD connect construction begin --", + " // -- elephc optional PDO_FIREBIRD connect construction end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_FIREBIRD class begin --", + " // -- elephc optional PDO_FIREBIRD class end --", + ); + } + } else if php_version >= PhpVersion::Php85 { + for (legacy_name, namespaced_name) in [ + ("FB_ATTR_DATE_FORMAT", "ATTR_DATE_FORMAT"), + ("FB_ATTR_TIME_FORMAT", "ATTR_TIME_FORMAT"), + ("FB_ATTR_TIMESTAMP_FORMAT", "ATTR_TIMESTAMP_FORMAT"), + ] { + assert!( + source.contains(&format!(" const {legacy_name} =")), + "missing PDO_FIREBIRD alias {legacy_name}" + ); + *source = source.replacen( + &format!(" const {legacy_name} ="), + &format!(" #[\\Deprecated(\"use Pdo\\\\Firebird::{namespaced_name} instead\")]\n const {legacy_name} ="), + 1, + ); + } + } + + let odbc_enabled = cfg!(feature = "pdo-odbc") + || std::env::var_os("ELEPHC_PDO_ODBC").is_some(); + if !odbc_enabled { + remove_version_block( + source, + "// -- elephc optional PDO_ODBC global type begin --", + "// -- elephc optional PDO_ODBC global type end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_ODBC aliases begin --", + " // -- elephc optional PDO_ODBC aliases end --", + ); + if php_version >= PhpVersion::Php84 { + remove_version_block( + source, + " // -- elephc optional PDO_ODBC connect dispatch begin --", + " // -- elephc optional PDO_ODBC connect dispatch end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_ODBC connect construction begin --", + " // -- elephc optional PDO_ODBC connect construction end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_ODBC class begin --", + " // -- elephc optional PDO_ODBC class end --", + ); + } + } else if php_version >= PhpVersion::Php85 { + for (legacy_name, namespaced_name) in [ + ("ODBC_ATTR_USE_CURSOR_LIBRARY", "ATTR_USE_CURSOR_LIBRARY"), + ("ODBC_ATTR_ASSUME_UTF8", "ATTR_ASSUME_UTF8"), + ("ODBC_SQL_USE_IF_NEEDED", "SQL_USE_IF_NEEDED"), + ("ODBC_SQL_USE_ODBC", "SQL_USE_ODBC"), + ("ODBC_SQL_USE_DRIVER", "SQL_USE_DRIVER"), + ] { + assert!( + source.contains(&format!(" const {legacy_name} =")), + "missing PDO_ODBC alias {legacy_name}" + ); + *source = source.replacen( + &format!(" const {legacy_name} ="), + &format!(" #[\\Deprecated(\"use Pdo\\\\Odbc::{namespaced_name} instead\")]\n const {legacy_name} ="), + 1, + ); + } + } + + let ibm_enabled = cfg!(feature = "pdo-ibm") + || std::env::var_os("ELEPHC_PDO_IBM").is_some(); + if !ibm_enabled { + remove_version_block( + source, + " // -- elephc optional PDO_IBM aliases begin --", + " // -- elephc optional PDO_IBM aliases end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_IBM subclass guard begin --", + " // -- elephc optional PDO_IBM subclass guard end --", + ); + if php_version >= PhpVersion::Php84 { + remove_version_block( + source, + " // -- elephc optional PDO_IBM connect dispatch begin --", + " // -- elephc optional PDO_IBM connect dispatch end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_IBM connect construction begin --", + " // -- elephc optional PDO_IBM connect construction end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_IBM class begin --", + " // -- elephc optional PDO_IBM class end --", + ); + } + } else if php_version >= PhpVersion::Php85 { + for (legacy_name, namespaced_name) in [ + ("SQL_ATTR_INFO_USERID", "ATTR_INFO_USERID"), + ("SQL_ATTR_INFO_ACCTSTR", "ATTR_INFO_ACCTSTR"), + ("SQL_ATTR_INFO_APPLNAME", "ATTR_INFO_APPLNAME"), + ("SQL_ATTR_INFO_WRKSTNNAME", "ATTR_INFO_WRKSTNNAME"), + ("SQL_ATTR_USE_TRUSTED_CONTEXT", "ATTR_USE_TRUSTED_CONTEXT"), + ("SQL_ATTR_TRUSTED_CONTEXT_USERID", "ATTR_TRUSTED_CONTEXT_USERID"), + ("SQL_ATTR_TRUSTED_CONTEXT_PASSWORD", "ATTR_TRUSTED_CONTEXT_PASSWORD"), + ] { + assert!( + source.contains(&format!(" const {legacy_name} =")), + "missing PDO_IBM alias {legacy_name}" + ); + *source = source.replacen( + &format!(" const {legacy_name} ="), + &format!(" #[\\Deprecated(\"use Pdo\\\\Ibm::{namespaced_name} instead\")]\n const {legacy_name} ="), + 1, + ); + } + } + + let sqlsrv_requested = cfg!(feature = "pdo-sqlsrv") + || std::env::var_os("ELEPHC_PDO_SQLSRV").is_some(); + let sqlsrv_enabled = sqlsrv_requested + && php_version >= PhpVersion::Php83 + && php_version <= PhpVersion::Php85; + if !sqlsrv_enabled { + *source = source.replace( + " $_drivers[] = elephc_pdo_available_driver_name($_index);", + " $_availableDriver = elephc_pdo_available_driver_name($_index);\n if ($_availableDriver === \"sqlsrv\") {\n continue;\n }\n $_drivers[] = $_availableDriver;", + ); + *source = source.replace( + "function pdo_drivers(): array {\n $_drivers = [];\n $_count = elephc_pdo_available_driver_count();\n for ($_index = 0; $_index < $_count; $_index++) {\n $_drivers[] = elephc_pdo_available_driver_name($_index);\n }", + "function pdo_drivers(): array {\n $_drivers = [];\n $_count = elephc_pdo_available_driver_count();\n for ($_index = 0; $_index < $_count; $_index++) {\n $_availableDriver = elephc_pdo_available_driver_name($_index);\n if ($_availableDriver === \"sqlsrv\") {\n continue;\n }\n $_drivers[] = $_availableDriver;\n }", + ); + remove_version_block( + source, + " // -- elephc optional PDO_SQLSRV constants begin --", + " // -- elephc optional PDO_SQLSRV constants end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_SQLSRV subclass guard begin --", + " // -- elephc optional PDO_SQLSRV subclass guard end --", + ); + if php_version >= PhpVersion::Php84 { + remove_version_block( + source, + " // -- elephc optional PDO_SQLSRV connect dispatch begin --", + " // -- elephc optional PDO_SQLSRV connect dispatch end --", + ); + } + } + + let oci_enabled = cfg!(feature = "pdo-oci") + || std::env::var_os("ELEPHC_PDO_OCI").is_some(); + if !oci_enabled { + remove_version_block( + source, + " // -- elephc optional PDO_OCI aliases begin --", + " // -- elephc optional PDO_OCI aliases end --", + ); + remove_version_block( + source, + " // -- elephc optional PDO_OCI subclass guard begin --", + " // -- elephc optional PDO_OCI subclass guard end --", + ); + if php_version >= PhpVersion::Php84 { + remove_version_block( + source, + " // -- elephc optional PDO_OCI connect dispatch begin --", + " // -- elephc optional PDO_OCI connect dispatch end --", + ); + } + } +} + +/// Removes one inclusive source fragment delimited by stable version-gate comments. +/// Panics when either marker is missing because a renamed prelude marker must fail +/// compiler tests loudly instead of silently exposing a method in the wrong PHP version. +fn remove_version_block(source: &mut String, begin: &str, end: &str) { + let start = source + .find(begin) + .unwrap_or_else(|| panic!("missing PDO prelude version-gate marker: {begin}")); + let relative_end = source[start..] + .find(end) + .unwrap_or_else(|| panic!("missing PDO prelude version-gate marker: {end}")); + let mut finish = start + relative_end + end.len(); + if source.as_bytes().get(finish) == Some(&b'\n') { + finish += 1; + } + source.replace_range(start..finish, ""); +} + +#[cfg(test)] +mod version_tests { + use super::*; + + /// Verifies the core ATTR_STATEMENT_CLASS contract remains present for every + /// supported PHP compatibility target from 8.0 through 8.6. + #[test] + fn all_versions_keep_statement_class_support() { + for version in PhpVersion::ALL { + let source = prelude_source_for_version(version); + assert!(source.contains("const ATTR_STATEMENT_CLASS = 13;")); + assert!(source.contains("private array $statementClassConfig;")); + assert!(source.contains("__elephc_pdo_statement_class_status")); + assert!(source.contains("__elephc_invoke_pdo_statement_constructor")); + } + } + + /// Keeps PDO_SQLSRV 5.13.1 out of PHP versions its published binaries do not support. + #[test] + fn sqlsrv_is_limited_to_supported_php_versions() { + for version in [PhpVersion::Php80, PhpVersion::Php81, PhpVersion::Php82, PhpVersion::Php86] { + let source = prelude_source_for_version(version); + assert!(!source.contains("const SQLSRV_ATTR_ENCODING = 1000;")); + assert!(source.contains("$_availableDriver === \"sqlsrv\"")); + } + } + + /// Verifies PHP 8.4 keeps the historical high-bit fetch flag values and decoder mask. + #[test] + fn php84_source_keeps_high_fetch_flags() { + let source = prelude_source_for_version(PhpVersion::Php84); + assert!(source.contains("const FETCH_GROUP = 0x10000;")); + assert!(source.contains("$_base = $mode & 0xFFFF;")); + } + + /// Verifies PHP 8.0-8.3 retain legacy driver methods without exposing the + /// namespaced PHP 8.4 classes or `PDO::connect()` factory. + #[test] + fn php83_source_uses_legacy_driver_surface() { + let source = prelude_source_for_version(PhpVersion::Php83); + assert!(source.contains("public function sqliteCreateFunction")); + assert!(source.contains("public function pgsqlCopyFromArray")); + assert!(!source.contains("public static function connect")); + assert!(!source.contains("namespace Pdo {")); + let tokens = crate::lexer::tokenize(source.as_ref()).expect("tokenize PHP 8.3 PDO prelude"); + crate::parser::parse_internal(&tokens).expect("parse PHP 8.3 PDO prelude"); + } + + /// Verifies PHP 8.0 keeps query text as private implementation storage because + /// PDOStatement/PDORow only gained public `queryString` properties in PHP 8.1. + #[test] + fn php80_source_hides_query_string_properties() { + let source = prelude_source_for_version(PhpVersion::Php80); + assert_eq!(source.matches("private string $queryString;").count(), 2); + assert!(!source.contains("public readonly string $queryString;")); + let tokens = crate::lexer::tokenize(source.as_ref()).expect("tokenize PHP 8.0 PDO prelude"); + crate::parser::parse_internal(&tokens).expect("parse PHP 8.0 PDO prelude"); + } + + /// Verifies PHP 8.1 exposes both public query-string properties while retaining + /// the legacy, non-namespaced driver surface used until PHP 8.3. + #[test] + fn php81_source_exposes_query_string_properties() { + let source = prelude_source_for_version(PhpVersion::Php81); + assert_eq!(source.matches("public readonly string $queryString;").count(), 2); + assert!(!source.contains("namespace Pdo {")); + assert!(!source.contains("#[\\SensitiveParameter]")); + assert!(prelude_source_for_version(PhpVersion::Php82) + .contains("#[\\SensitiveParameter] ?string $password")); + } + + /// Verifies PHP 8.5 emits the compact flag values and updates every executable mask. + #[test] + fn php85_source_uses_compact_fetch_flags() { + let source = prelude_source_for_version(PhpVersion::Php85); + assert!(source.contains("const FETCH_GROUP = 0x20;")); + assert!(source.contains("const FETCH_UNIQUE = 0x40;")); + assert!(source.contains("const FETCH_PROPS_LATE = 0x100;")); + assert!(source.contains("$_base = $mode & 0xF;")); + assert!(!source.contains("$_base = $mode & 0xFFFF;")); + assert!(source.contains("$_unique = ($mode & 0x40) != 0;")); + assert!(source.contains("public function setAuthorizer(?callable $callback): void")); + assert!(source.contains( + "public function pgsqlCopyFromArray(string $tableName, array $rows," + )); + } + + /// Verifies the generated PHP 8.5 prelude remains valid lexer and parser input. + #[test] + fn php85_source_tokenizes_and_parses() { + let source = prelude_source_for_version(PhpVersion::Php85); + assert!(source.contains( + "#[\\Deprecated(\"as it has no effect\")]\n const TRANSACTION_IDLE = 0;" + )); + assert!(!prelude_source_for_version(PhpVersion::Php84) + .contains("#[\\Deprecated(\"as it has no effect\")]")); + let tokens = crate::lexer::tokenize(source.as_ref()).expect("tokenize PHP 8.5 PDO prelude"); + crate::parser::parse_internal(&tokens).expect("parse PHP 8.5 PDO prelude"); + } + + /// PHP 8.5+ alone enables lazy simple-query consumption on PostgreSQL statements. + #[test] + fn pgsql_simple_streaming_is_version_gated() { + assert!(!prelude_source_for_version(PhpVersion::Php84) + .contains("elephc_pdo_stmt_enable_simple_streaming($_handle)")); + assert!(prelude_source_for_version(PhpVersion::Php85) + .contains("elephc_pdo_stmt_enable_simple_streaming($_handle)")); + assert!(prelude_source_for_version(PhpVersion::Php86) + .contains("elephc_pdo_stmt_enable_simple_streaming($_handle)")); + } + + /// PDO_DBLIB keeps legacy constants on older targets, adds `Pdo\Dblib` in + /// PHP 8.4, and deprecates only the legacy aliases beginning with PHP 8.5. + #[cfg(feature = "pdo-dblib")] + #[test] + fn dblib_surface_and_alias_deprecations_are_version_gated() { + let php83 = prelude_source_for_version(PhpVersion::Php83); + assert!(php83.contains("const DBLIB_ATTR_CONNECTION_TIMEOUT = 1000;")); + assert!(!php83.contains("class Dblib extends \\PDO")); + assert!(!php83.contains("use Pdo\\Dblib::ATTR_CONNECTION_TIMEOUT instead")); + + let php84 = prelude_source_for_version(PhpVersion::Php84); + assert!(php84.contains("class Dblib extends \\PDO")); + assert!(!php84.contains("#[\\Deprecated(\"use Pdo\\\\Dblib::ATTR_CONNECTION_TIMEOUT instead\")]")); + + let php85 = prelude_source_for_version(PhpVersion::Php85); + assert!(php85.contains("#[\\Deprecated(\"use Pdo\\\\Dblib::ATTR_CONNECTION_TIMEOUT instead\")]")); + } + + /// PDO_FIREBIRD keeps its three legacy aliases on old targets, adds the + /// namespaced driver in PHP 8.4, and deprecates only those aliases in 8.5. + #[cfg(feature = "pdo-firebird")] + #[test] + fn firebird_surface_and_alias_deprecations_are_version_gated() { + let php83 = prelude_source_for_version(PhpVersion::Php83); + assert!(php83.contains("const FB_ATTR_DATE_FORMAT = 1000;")); + assert!(!php83.contains("class Firebird extends \\PDO")); + + let php84 = prelude_source_for_version(PhpVersion::Php84); + assert!(php84.contains("class Firebird extends \\PDO")); + assert!(php84.contains("public static function getApiVersion(): int")); + assert!(!php84.contains("use Pdo\\Firebird::ATTR_DATE_FORMAT instead")); + + let php85 = prelude_source_for_version(PhpVersion::Php85); + assert!(php85.contains( + "#[\\Deprecated(\"use Pdo\\\\Firebird::ATTR_DATE_FORMAT instead\")]" + )); + assert!(!php85.contains("const FB_TRANSACTION_ISOLATION_LEVEL")); + } + + /// PDO_ODBC keeps global/legacy constants on all targets, adds `Pdo\Odbc` + /// in PHP 8.4, and deprecates legacy aliases beginning with PHP 8.5. + #[cfg(feature = "pdo-odbc")] + #[test] + fn odbc_surface_and_alias_deprecations_are_version_gated() { + let php83 = prelude_source_for_version(PhpVersion::Php83); + assert!(php83.contains("const PDO_ODBC_TYPE = \"unixODBC\";")); + assert!(php83.contains("const ODBC_ATTR_ASSUME_UTF8 = 1001;")); + assert!(!php83.contains("class Odbc extends \\PDO")); + + let php84 = prelude_source_for_version(PhpVersion::Php84); + assert!(php84.contains("class Odbc extends \\PDO")); + assert!(php84.contains("const ATTR_USE_CURSOR_LIBRARY = 1000;")); + assert!(!php84.contains("use Pdo\\Odbc::ATTR_ASSUME_UTF8 instead")); + + let php85 = prelude_source_for_version(PhpVersion::Php85); + assert!(php85.contains( + "#[\\Deprecated(\"use Pdo\\\\Odbc::ATTR_ASSUME_UTF8 instead\")]" + )); + let tokens = crate::lexer::tokenize(php85.as_ref()).expect("tokenize PHP 8.5 ODBC prelude"); + crate::parser::parse_internal(&tokens).expect("parse PHP 8.5 ODBC prelude"); + } + + /// PDO_IBM 1.7.0 keeps legacy aliases, adds `Pdo\Ibm` in PHP 8.4, and + /// deprecates the legacy spellings beginning with PHP 8.5. + #[cfg(feature = "pdo-ibm")] + #[test] + fn ibm_surface_and_alias_deprecations_are_version_gated() { + let php83 = prelude_source_for_version(PhpVersion::Php83); + assert!(php83.contains("const SQL_ATTR_INFO_USERID = 1281;")); + assert!(!php83.contains("class Ibm extends \\PDO")); + + let php84 = prelude_source_for_version(PhpVersion::Php84); + assert!(php84.contains("class Ibm extends \\PDO")); + assert!(php84.contains("const ATTR_USE_TRUSTED_CONTEXT = 2561;")); + assert!(!php84.contains("use Pdo\\Ibm::ATTR_INFO_USERID instead")); + + let php85 = prelude_source_for_version(PhpVersion::Php85); + assert!(php85.contains( + "#[\\Deprecated(\"use Pdo\\\\Ibm::ATTR_INFO_USERID instead\")]" + )); + let tokens = crate::lexer::tokenize(php85.as_ref()).expect("tokenize PHP 8.5 IBM prelude"); + crate::parser::parse_internal(&tokens).expect("parse PHP 8.5 IBM prelude"); + } + + /// PDO_CUBRID keeps its historical PDO-only API on every supported PHP target. + #[cfg(feature = "pdo-cubrid")] + #[test] + fn cubrid_surface_is_available_without_a_namespaced_subclass() { + for version in PhpVersion::ALL { + let source = prelude_source_for_version(version); + assert!(source.contains("const CUBRID_ATTR_ISOLATION_LEVEL = 1000;")); + assert!(source.contains("const CUBRID_SCH_ATTR_WITH_SYNONYM = 20;")); + assert!(source.contains("public function cubrid_schema(")); + assert!(!source.contains("class Cubrid extends \\PDO")); + let tokens = crate::lexer::tokenize(source.as_ref()) + .expect("tokenize PDO_CUBRID prelude"); + crate::parser::parse_internal(&tokens).expect("parse PDO_CUBRID prelude"); + } + } + + /// PDO_OCI keeps its legacy PDO constants and never defines a `Pdo\Oci` subclass. + #[cfg(feature = "pdo-oci")] + #[test] + fn oci_surface_follows_bundled_and_pecl_versions() { + for version in PhpVersion::ALL { + let source = prelude_source_for_version(version); + assert!(source.contains("const OCI_ATTR_ACTION = 1000;")); + assert!(source.contains("const OCI_ATTR_CALL_TIMEOUT = 1004;")); + assert!(!source.contains("class Oci extends \\PDO")); + } + let php83 = prelude_source_for_version(PhpVersion::Php83); + assert!(!php83.contains("optional PDO_OCI connect dispatch")); + let php84 = prelude_source_for_version(PhpVersion::Php84); + assert!(php84.contains("optional PDO_OCI connect dispatch")); + let tokens = crate::lexer::tokenize(php84.as_ref()).expect("tokenize PHP 8.4 OCI prelude"); + crate::parser::parse_internal(&tokens).expect("parse PHP 8.4 OCI prelude"); + } + + /// Every supported PHP target generates syntactically valid PDO source, while + /// PHP 8.6 alone enables PostgreSQL's new persistent-session reset behavior. + #[test] + fn every_version_source_tokenizes_and_php86_enables_session_reset() { + for version in PhpVersion::ALL { + let source = prelude_source_for_version(version); + let tokens = crate::lexer::tokenize(source.as_ref()) + .unwrap_or_else(|error| panic!("tokenize PHP {version} PDO prelude: {error}")); + crate::parser::parse_internal(&tokens) + .unwrap_or_else(|error| panic!("parse PHP {version} PDO prelude: {error}")); + } + assert!(prelude_source_for_version(PhpVersion::Php85) + .contains("elephc_pdo_release($this->conn, 0);")); + assert!(prelude_source_for_version(PhpVersion::Php86) + .contains("elephc_pdo_release($this->conn, 1);")); + } +} diff --git a/src/pdo_prelude/detect.rs b/src/pdo_prelude/detect.rs index 1c6f0b0fd0..71835d4c7e 100644 --- a/src/pdo_prelude/detect.rs +++ b/src/pdo_prelude/detect.rs @@ -1,8 +1,8 @@ //! Purpose: //! Decides whether a parsed program references the PDO standard-library classes -//! (`PDO`, `PDOStatement`, `PDOException`) so the prelude is injected only for -//! PDO-using programs. Replaces a `format!("{:?}")` substring scan with a precise -//! AST walk that inspects only class-name positions. +//! (`PDO`, `PDOStatement`, `PDOException`) or calls the global `pdo_drivers()` +//! function, so the prelude is injected only for PDO-using programs. Replaces a +//! `format!("{:?}")` substring scan with a precise AST walk. //! //! Called from: //! - `crate::pdo_prelude::inject_if_used`. @@ -11,7 +11,9 @@ //! - Runs before name resolution, so `Name`s are raw source text: a reference may //! be written `PDO`, `\PDO`, or `\Some\PDO`, and PHP class names are //! case-insensitive. The walk therefore matches the unqualified last segment -//! case-insensitively. +//! case-insensitively. PHP 8.4 driver subclasses, including optional `Pdo\Dblib`, +//! are additionally matched as two-segment `Pdo\` names +//! so that a program referencing only a subclass still injects the prelude. //! - Soundness over precision: a missed reference would drop the prelude and break //! compilation, so the `match`es are exhaustive (no wildcard arm). Adding an AST //! node forces this file to be updated. False positives (e.g. a class literally @@ -22,7 +24,7 @@ //! name only in the import, and the later `new Db()` carries the alias, which //! the walk cannot otherwise connect back to PDO — so skipping imports would be //! a false negative. Function/constant name positions are not class references -//! and are skipped. +//! and are skipped, except for PHP's case-insensitive global `pdo_drivers()`. use crate::names::Name; use crate::parser::ast::{ @@ -37,15 +39,47 @@ pub(super) fn program_uses_pdo(program: &[Stmt]) -> bool { program.iter().any(stmt_refs_pdo) } -/// Returns whether `name`'s unqualified last segment is one of the PDO classes, -/// compared case-insensitively to match PHP's case-insensitive class names and -/// any namespace/leading-backslash form (`PDO`, `\PDO`, `\Some\PDO`). +/// Returns whether `name`'s unqualified last segment is one of the base PDO +/// classes, or whether `name` denotes a `Pdo\` driver subclass. The base classes +/// are compared case-insensitively to match PHP's case-insensitive class names and +/// any namespace/leading-backslash form (`PDO`, `\PDO`, `\Some\PDO`); the driver +/// subclasses are matched by `name_is_pdo_driver_subclass`. fn name_is_pdo(name: &Name) -> bool { name.last_segment().is_some_and(|segment| { segment.eq_ignore_ascii_case("PDO") || segment.eq_ignore_ascii_case("PDOStatement") || segment.eq_ignore_ascii_case("PDOException") - }) + || segment.eq_ignore_ascii_case("PDORow") + }) || name_is_pdo_driver_subclass(name) +} + +/// Returns whether a function-call name denotes PHP's global `pdo_drivers()` +/// helper. Only the single-segment global name matches; a namespaced function +/// with the same short name is unrelated. PHP function names are +/// case-insensitive, so the comparison is too. +fn name_is_pdo_drivers(name: &Name) -> bool { + matches!(name.parts.as_slice(), [function] if function.eq_ignore_ascii_case("pdo_drivers")) +} + +/// Returns whether `name` denotes one of PHP 8.4's `Pdo\` driver subclasses +/// (`Pdo\Sqlite`, `Pdo\Mysql`, `Pdo\Pgsql`, plus optional drivers). Matched only as a two-segment name +/// whose namespace segment is `Pdo` and short name is a driver, both compared +/// case-insensitively, so `Pdo\Sqlite` and `\Pdo\Mysql` are recognized while an +/// unrelated `App\Sqlite` or a deeper `Vendor\Pdo\Sqlite` is not. Injecting the +/// prelude for these names is what makes the subclasses resolvable even when a +/// program never mentions the base `PDO` class. +fn name_is_pdo_driver_subclass(name: &Name) -> bool { + matches!( + name.parts.as_slice(), + [namespace, driver] + if namespace.eq_ignore_ascii_case("Pdo") + && (driver.eq_ignore_ascii_case("Sqlite") + || driver.eq_ignore_ascii_case("Mysql") + || driver.eq_ignore_ascii_case("Pgsql") + || driver.eq_ignore_ascii_case("Dblib") + || driver.eq_ignore_ascii_case("Firebird") + || driver.eq_ignore_ascii_case("Odbc")) + ) } /// Returns whether a static receiver names a PDO class (`PDO::...`). `self`, @@ -207,8 +241,10 @@ fn expr_refs_pdo(expr: &Expr) -> bool { || result_target.as_deref().is_some_and(expr_refs_pdo) || prelude.iter().any(stmt_refs_pdo) } - ExprKind::FunctionCall { args, .. } - | ExprKind::ClosureCall { args, .. } => args.iter().any(expr_refs_pdo), + ExprKind::FunctionCall { name, args } => { + name_is_pdo_drivers(name) || args.iter().any(expr_refs_pdo) + } + ExprKind::ClosureCall { args, .. } => args.iter().any(expr_refs_pdo), ExprKind::ArrayLiteral(items) => items.iter().any(expr_refs_pdo), ExprKind::ArrayLiteralAssoc(pairs) => pairs .iter() @@ -598,6 +634,82 @@ mod tests { ))); } + /// A `Pdo\Sqlite` driver-subclass reference (no base `PDO` mention) is + /// detected, so the prelude carrying the subclass is injected. + #[test] + fn detects_driver_subclass_qualified() { + assert!(program_uses_pdo(&parse( + r#"` form matches. + #[test] + fn ignores_deeper_pdo_path() { + assert!(!program_uses_pdo(&parse( + r#" Option>> { /// tested against. pub fn newest_admitted(constraint: &str) -> Option { let alternatives = parse(constraint)?; - PhpVersion::ALL + PhpVersion::MAINTAINED .iter() .rev() .copied() diff --git a/src/php_profile/resolve.rs b/src/php_profile/resolve.rs index 856e9ff3ad..77141ec5e7 100644 --- a/src/php_profile/resolve.rs +++ b/src/php_profile/resolve.rs @@ -75,14 +75,14 @@ fn classify(raw: &str) -> Pin { return Pin::Unparsable; }; let wanted = major * 10_000 + minor * 100; - if let Some(profile) = PhpVersion::ALL + if let Some(profile) = PhpVersion::MAINTAINED .iter() .copied() .find(|profile| profile.version_id() == wanted) { return Pin::Exact(profile); } - let oldest = PhpVersion::ALL[0]; + let oldest = PhpVersion::MAINTAINED[0]; if wanted < oldest.version_id() { Pin::TooOld } else { @@ -92,8 +92,8 @@ fn classify(raw: &str) -> Pin { /// Turns a classified pin into a profile, recording a note when the answer had to be moved. fn apply(raw: &str, source: &str, notes: &mut Vec) -> Option { - let oldest = PhpVersion::ALL[0]; - let newest = PhpVersion::ALL[PhpVersion::ALL.len() - 1]; + let oldest = PhpVersion::MAINTAINED[0]; + let newest = PhpVersion::MAINTAINED[PhpVersion::MAINTAINED.len() - 1]; match classify(raw) { Pin::Exact(profile) => Some(profile), Pin::TooOld => { @@ -201,7 +201,7 @@ fn resolve_in(dir: &Path, notes: &mut Vec) -> Option<(PhpVersion, Proven // inside one is a judgement call, and this makes the call only in the case where every // reasonable reading agrees: the project has explicitly ruled newer PHP out. if let Some(raw) = manifest.string_at(&["require", "php"]) { - let newest = PhpVersion::ALL[PhpVersion::ALL.len() - 1]; + let newest = PhpVersion::MAINTAINED[PhpVersion::MAINTAINED.len() - 1]; if let Some(admitted) = crate::php_profile::constraint::newest_admitted(raw) { if admitted.version_id() < newest.version_id() { return Some((admitted, Provenance::ComposerRequire)); @@ -435,7 +435,7 @@ mod tests { r#"{"config":{"platform":{"php":"8.1.0"}}}"#, ); let resolved = resolve(&dir.join("prog.php")); - assert_eq!(resolved.profile, PhpVersion::ALL[0]); + assert_eq!(resolved.profile, PhpVersion::MAINTAINED[0]); assert_eq!(resolved.notes.len(), 1); assert!(resolved.notes[0].contains("8.1.0")); let _ = std::fs::remove_dir_all(&dir); @@ -448,7 +448,10 @@ mod tests { write(&dir, "prog.php", " = - PhpVersion::ALL.iter().map(|p| p.version_string()).collect(); + PhpVersion::MAINTAINED + .iter() + .map(|p| p.version_string()) + .collect(); let distinct_ids: std::collections::HashSet<_> = - PhpVersion::ALL.iter().map(|p| p.version_id()).collect(); + PhpVersion::MAINTAINED + .iter() + .map(|p| p.version_id()) + .collect(); let distinct_minors: std::collections::HashSet<_> = - PhpVersion::ALL.iter().map(|p| p.minor()).collect(); - assert_eq!(distinct_strings.len(), PhpVersion::ALL.len()); - assert_eq!(distinct_ids.len(), PhpVersion::ALL.len()); - assert_eq!(distinct_minors.len(), PhpVersion::ALL.len()); + PhpVersion::MAINTAINED.iter().map(|p| p.minor()).collect(); + assert_eq!(distinct_strings.len(), PhpVersion::MAINTAINED.len()); + assert_eq!(distinct_ids.len(), PhpVersion::MAINTAINED.len()); + assert_eq!(distinct_minors.len(), PhpVersion::MAINTAINED.len()); } /// Every name `eval` is matched on is itself a table entry. diff --git a/src/php_version.rs b/src/php_version.rs new file mode 100644 index 0000000000..d889638ff5 --- /dev/null +++ b/src/php_version.rs @@ -0,0 +1,205 @@ +//! Purpose: +//! Defines the selected PHP compatibility version for version-sensitive compiler surfaces. +//! Keeps parsing, ordering, and numeric `PHP_VERSION_ID` conversion in one typed model. +//! +//! Called from: +//! - `crate::cli::parse_args()` when normalizing `--php-version`. +//! - Version-sensitive standard-library preludes such as `crate::pdo_prelude`. +//! +//! Key details: +//! - PHP 8.5 is the default maintained profile, matching the current compiler baseline. +//! - Ordering is semantic because every supported value has the same major version. + +use std::fmt; +use std::str::FromStr; + +/// PHP compatibility versions accepted by the compiler. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum PhpVersion { + Php80, + Php81, + Php82, + Php83, + Php84, + Php85, + Php86, +} + +impl PhpVersion { + /// Every accepted version in ascending semantic order. + pub const ALL: [Self; 7] = [ + Self::Php80, + Self::Php81, + Self::Php82, + Self::Php83, + Self::Php84, + Self::Php85, + Self::Php86, + ]; + + /// Maintained stable profiles used for automatic project-profile selection. + /// + /// Historical 8.0/8.1 and preview 8.6 remain explicitly selectable, but an + /// unpinned project is never moved to either end of that compatibility range. + pub const MAINTAINED: [Self; 4] = [ + Self::Php82, + Self::Php83, + Self::Php84, + Self::Php85, + ]; + + /// Returns the canonical CLI spelling for this compatibility version. + pub const fn as_str(self) -> &'static str { + match self { + Self::Php80 => "8.0", + Self::Php81 => "8.1", + Self::Php82 => "8.2", + Self::Php83 => "8.3", + Self::Php84 => "8.4", + Self::Php85 => "8.5", + Self::Php86 => "8.6", + } + } + + /// Returns the canonical `major.minor` spelling accepted by the CLI. + pub const fn spelling(self) -> &'static str { + self.as_str() + } + + /// Parses an accepted `major.minor` spelling without emitting a diagnostic. + pub fn parse(value: &str) -> Option { + Self::ALL + .iter() + .copied() + .find(|profile| profile.spelling() == value) + } + + /// Returns the comma-separated values accepted by `--php-version`. + pub fn accepted_values() -> String { + Self::ALL + .iter() + .map(|version| version.as_str()) + .collect::>() + .join(", ") + } + + /// Returns PHP's numeric `PHP_VERSION_ID` representation for this profile. + pub const fn version_id(self) -> u32 { + match self { + Self::Php80 => 80000, + Self::Php81 => 80100, + Self::Php82 => 80200, + Self::Php83 => 80300, + Self::Php84 => 80400, + Self::Php85 => 80500, + Self::Php86 => 80600, + } + } + + /// Returns the profile's `PHP_VERSION` and `phpversion()` value. + pub const fn version_string(self) -> &'static str { + match self { + Self::Php80 => "8.0.0", + Self::Php81 => "8.1.0", + Self::Php82 => "8.2.0", + Self::Php83 => "8.3.0", + Self::Php84 => "8.4.0", + Self::Php85 => "8.5.0", + Self::Php86 => "8.6.0", + } + } + + /// Returns `PHP_MAJOR_VERSION` for this profile. + pub const fn major(self) -> u32 { + self.version_id() / 10_000 + } + + /// Returns `PHP_MINOR_VERSION` for this profile. + pub const fn minor(self) -> u32 { + (self.version_id() / 100) % 100 + } + + /// Returns `PHP_RELEASE_VERSION`, pinned to zero for language profiles. + pub const fn release(self) -> u32 { + self.version_id() % 100 + } + + /// Returns the empty prerelease suffix used by stable language profiles. + pub const fn extra_version(self) -> &'static str { + "" + } + + /// Returns the matching Zend Engine language-profile version. + pub const fn zend_version(self) -> &'static str { + match self { + Self::Php80 => "4.0.0", + Self::Php81 => "4.1.0", + Self::Php82 => "4.2.0", + Self::Php83 => "4.3.0", + Self::Php84 => "4.4.0", + Self::Php85 => "4.5.0", + Self::Php86 => "4.6.0", + } + } +} + +impl Default for PhpVersion { + /// Selects the newest maintained PHP compatibility profile by default. + fn default() -> Self { + Self::Php85 + } +} + +impl fmt::Display for PhpVersion { + /// Formats the version in canonical `major.minor` form. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for PhpVersion { + type Err = String; + + /// Parses an exact supported `major.minor` spelling without accepting patch versions. + fn from_str(value: &str) -> Result { + match value { + "8.0" => Ok(Self::Php80), + "8.1" => Ok(Self::Php81), + "8.2" => Ok(Self::Php82), + "8.3" => Ok(Self::Php83), + "8.4" => Ok(Self::Php84), + "8.5" => Ok(Self::Php85), + "8.6" => Ok(Self::Php86), + other => Err(format!( + "Invalid PHP version '{}': expected one of: {}", + other, + Self::accepted_values() + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verifies every advertised version round-trips through its canonical CLI spelling. + #[test] + fn supported_versions_round_trip() { + for version in PhpVersion::ALL { + assert_eq!(version.as_str().parse::(), Ok(version)); + } + } + + /// Verifies patch versions are rejected so compatibility selection is never ambiguous. + #[test] + fn patch_versions_are_rejected() { + assert!("8.4.1".parse::().is_err()); + } + + /// Verifies the enum's derived ordering follows semantic PHP version order. + #[test] + fn versions_have_semantic_order() { + assert!(PhpVersion::Php84 < PhpVersion::Php85); + } +} diff --git a/src/pipeline.rs b/src/pipeline.rs index f27f176219..c22e98b925 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -142,7 +142,15 @@ pub(crate) fn compile(config: CliConfig) { // Runs after include resolution so PDO usage inside includes is detected. crate::progress::phase("pdo-prelude"); let phase_started = Instant::now(); - let ast = pdo_prelude::inject_if_used(ast, with_crates.contains("pdo")); + let ast = if php_version == crate::web_prelude::PhpVersion::default() { + pdo_prelude::inject_if_used(ast, with_crates.contains("pdo")) + } else { + pdo_prelude::inject_if_used_for_version( + ast, + with_crates.contains("pdo"), + php_version, + ) + }; timings.record_since("pdo-prelude", phase_started); // Inject the timezone-introspection prelude (extern block + array marshalling, diff --git a/src/pipeline/backend.rs b/src/pipeline/backend.rs index cd969e2f77..5f38bd4daf 100644 --- a/src/pipeline/backend.rs +++ b/src/pipeline/backend.rs @@ -135,20 +135,6 @@ pub(super) fn emit_and_link(inputs: BackendInputs<'_>) { } codegen::set_linked_extensions(linked_extensions); - crate::progress::phase("runtime-cache"); - let phase_started = Instant::now(); - let runtime_pic = matches!(emit, Emit::Cdylib); - let runtime_object = match runtime_cache::prepare_runtime_object(heap_size, target, runtime_features, runtime_pic) { - Ok(runtime_object) => runtime_object, - Err(err) => { - crate::progress::clear(); - eprintln!("Runtime cache error: {}", err); - process::exit(1); - } - }; - timings.record_since("runtime-cache", phase_started); - timings.note(format!("Runtime cache: {}", runtime_object.status.as_str())); - crate::progress::phase("codegen"); let phase_started = Instant::now(); let user_asm = match codegen::generate_user_asm_from_ir_with_options( @@ -216,6 +202,25 @@ pub(super) fn emit_and_link(inputs: BackendInputs<'_>) { return; } + crate::progress::phase("runtime-cache"); + let phase_started = Instant::now(); + let runtime_pic = matches!(emit, Emit::Cdylib); + let runtime_object = match runtime_cache::prepare_runtime_object( + heap_size, + target, + runtime_features, + runtime_pic, + ) { + Ok(runtime_object) => runtime_object, + Err(err) => { + crate::progress::clear(); + eprintln!("Runtime cache error: {}", err); + process::exit(1); + } + }; + timings.record_since("runtime-cache", phase_started); + timings.note(format!("Runtime cache: {}", runtime_object.status.as_str())); + let native_requirements: Vec = runtime_link_requirements .iter() .filter_map(|requirement| match requirement { diff --git a/src/types/array_storage.rs b/src/types/array_storage.rs new file mode 100644 index 0000000000..f64a112222 --- /dev/null +++ b/src/types/array_storage.rs @@ -0,0 +1,73 @@ +//! Purpose: +//! Defines the two storage-representation conversions a PHP array local can undergo, as ONE +//! predicate shared by the type checker and the IR lowering. +//! +//! Called from: +//! - `crate::types::checker::inference::expr::effects` (the env fact a conditional arm leaves behind) +//! - `crate::ir_lower::context` and `crate::ir_lower::stmt::repr_fixpoint` (the op to emit) +//! +//! Key details: +//! - The checker's parameter specialization compiles a callee for the element type it sees at the +//! call site, so the checker and the lowering MUST agree on when an array's representation +//! changes: if they disagree, a boxed array is passed to a body compiled for raw scalar slots and +//! read back as a pointer. One predicate, used by both, is what keeps them in step. + +use super::PhpType; + +/// Returns the storage representation a local's type transition converts its array to, when the +/// transition is one of the two that REWRITE the array's storage at runtime. +/// +/// - `Array(T)` -> `Array(Mixed)` (`Op::ArrayToMixed`): every element slot is replaced by a pointer +/// to a boxed Mixed cell, so an op compiled against raw slots reads a pointer as a scalar. +/// - `Array(_)` -> `AssocArray` (`Op::ArrayToHash`): the packed element vector is replaced by a hash +/// table, so an op compiled against the packed layout reads the wrong memory entirely — and, +/// because a hash lookup of a live key simply misses instead of faulting, that one loses data +/// silently. +/// +/// A local with no previous type is not converted: there was no earlier representation for the code +/// above it to have been compiled against. A local leaving `AssocArray` is not either — no op +/// converts a hash back to packed storage, so such a transition REBINDS the local to a different +/// array rather than converting the one already there. +pub(crate) fn array_storage_conversion( + previous: Option<&PhpType>, + next: &PhpType, +) -> Option { + let PhpType::Array(previous_elem) = previous?.codegen_repr() else { + return None; + }; + match next.codegen_repr() { + PhpType::Array(next_elem) + if previous_elem.codegen_repr() != PhpType::Mixed + && next_elem.codegen_repr() == PhpType::Mixed => + { + Some(PhpType::Array(Box::new(PhpType::Mixed))) + } + assoc @ PhpType::AssocArray { .. } => Some(assoc), + _ => None, + } +} +/// Joins two conversion targets recorded for the SAME local into the one representation that +/// satisfies both. +/// +/// A region can convert one local along both axes on different paths (`if ($c) { $m[0] = "s"; } +/// else { $m["k"] = 1; }`). Entering it with the array merely boxed would leave the hash arm — now +/// lowered against packed storage it no longer has — writing through the wrong layout, so the join +/// of an indexed target with a hash target is the HASH. Two hash targets that disagree on the value +/// type join to a Mixed-valued hash, because the arm the other value type came from would otherwise +/// insert entries tagged differently from what the merge reads back. +pub(crate) fn join_array_storage_conversion(previous: &PhpType, next: &PhpType) -> PhpType { + match (previous.codegen_repr(), next.codegen_repr()) { + (PhpType::Array(_), PhpType::Array(_)) => PhpType::Array(Box::new(PhpType::Mixed)), + ( + PhpType::AssocArray { value: previous_value, .. }, + PhpType::AssocArray { value: next_value, .. }, + ) if previous_value.codegen_repr() == next_value.codegen_repr() => PhpType::AssocArray { + key: Box::new(PhpType::Mixed), + value: previous_value, + }, + _ => PhpType::AssocArray { + key: Box::new(PhpType::Mixed), + value: Box::new(PhpType::Mixed), + }, + } +} diff --git a/src/types/checker/functions/call_validation.rs b/src/types/checker/functions/call_validation.rs index ea6734c317..ea5bb4e136 100644 --- a/src/types/checker/functions/call_validation.rs +++ b/src/types/checker/functions/call_validation.rs @@ -303,7 +303,19 @@ impl Checker { .filter(|a| !matches!(a.kind, ExprKind::Spread(_))) .count(); let has_spread = args.iter().any(|a| matches!(a.kind, ExprKind::Spread(_))); - let required = sig.defaults.iter().filter(|d| d.is_none()).count(); + let regular_param_count = if sig.variadic.is_some() { + sig.params.len().saturating_sub(1) + } else { + sig.params.len() + }; + // The variadic collector is represented as the signature's final param + // with no default expression, but it never contributes to minimum arity. + let required = sig + .defaults + .iter() + .take(regular_param_count) + .filter(|default| default.is_none()) + .count(); if sig.ref_params.iter().any(|is_ref| *is_ref) && has_spread && !allow_by_ref_spread { return Err(CompileError::new( @@ -339,11 +351,6 @@ impl Checker { } } - let regular_param_count = if sig.variadic.is_some() { - sig.params.len().saturating_sub(1) - } else { - sig.params.len() - }; let variadic_elem_ty = sig.variadic.as_ref().and_then(|_| { sig.params.last().and_then(|(_, ty)| match ty { PhpType::Array(elem) => Some((**elem).clone()), diff --git a/src/types/checker/functions/resolution/specialization.rs b/src/types/checker/functions/resolution/specialization.rs index cc38c537b4..c061fba89c 100644 --- a/src/types/checker/functions/resolution/specialization.rs +++ b/src/types/checker/functions/resolution/specialization.rs @@ -231,19 +231,52 @@ impl Checker { } } } + // A DECLARED `array` hint is generic: it resolves to `Array(Mixed)` and is then + // specialized to the first call site's concrete element type. That narrowing has to be + // joined over ALL call sites, or a later `Array(Mixed)` argument is passed to a body + // compiled for raw scalar slots and read back as a POINTER. The `declared_params` gate + // below used to exclude it from the very widening machinery that exists to prevent + // exactly this, because `array` is, technically, declared. + // + // `array` is the only PHP hint that resolves to an `Array`/`AssocArray` type, so a + // declared parameter currently carrying one can only have come from a generic hint. + let declared = stored_sig + .declared_params + .get(seen_idx) + .copied() + .unwrap_or(false); + let generic_array_param = param_types + .get(seen_idx) + .is_some_and(|(_, ty)| { + matches!(ty, PhpType::Array(_) | PhpType::AssocArray { .. }) + }); if seen_idx < regular_param_count - && !stored_sig - .declared_params - .get(seen_idx) - .copied() - .unwrap_or(false) + && (!declared || generic_array_param) && !matches!(actual_ty, PhpType::Never | PhpType::Callable) && (!matches!(actual_ty, PhpType::Void) || crate::codegen::sentinels::null_repr_is_tagged()) { let key = (name.to_string(), seen_idx); let seen = self.param_specialization_seen.contains(&key); - if param_types[seen_idx].1 == PhpType::Int && !seen { + if Self::is_generic_array_hint(¶m_types[seen_idx].1) + && !seen + && matches!(actual_ty, PhpType::Array(_) | PhpType::AssocArray { .. }) + { + // Discard the generic `array` hint exactly once, mirroring the `Int` fallback + // below: adopt the first call's concrete array type so an all-`array` + // parameter is not immediately polluted back to `array` by unioning with + // its own declaration. The seen set marks the discard, so every LATER call + // widens instead of re-adopting — which is the whole point. + self.param_specialization_seen.insert(key); + let specialized = Self::specialize_generic_array_param_hint( + ¶m_types[seen_idx].1, + &actual_ty, + ); + if param_types[seen_idx].1 != specialized { + param_types[seen_idx].1 = specialized; + changed = true; + } + } else if param_types[seen_idx].1 == PhpType::Int && !seen { // Discard the `Int` fallback exactly once: adopt the type of the // first call so an all-`Str` (etc.) parameter is not polluted by // unioning the fallback. The seen set marks the discard so a real diff --git a/src/types/checker/inference/expr/class_refs.rs b/src/types/checker/inference/expr/class_refs.rs index a30935b4a0..daae2dea81 100644 --- a/src/types/checker/inference/expr/class_refs.rs +++ b/src/types/checker/inference/expr/class_refs.rs @@ -91,6 +91,18 @@ impl Checker { return self.resolve_type_expr(&type_expr, expr.span); } if let Some(value_expr) = info.constants.get(name).cloned() { + if let Some(reason) = info.constant_deprecations.get(name).cloned() { + let message = if reason.is_empty() { + format!("Use of deprecated class constant: {}::{}", cn, name) + } else { + format!( + "Use of deprecated class constant: {}::{} — {}", + cn, name, reason + ) + }; + self.warnings + .push(crate::errors::CompileWarning::new(expr.span, &message)); + } return self.infer_type(&value_expr, &TypeEnv::default()); } } diff --git a/src/types/checker/inference/expr/effects.rs b/src/types/checker/inference/expr/effects.rs index 349f99f132..60e14b4bb6 100644 --- a/src/types/checker/inference/expr/effects.rs +++ b/src/types/checker/inference/expr/effects.rs @@ -14,7 +14,7 @@ use crate::parser::ast::{BinOp, CallableTarget, Expr, ExprKind}; use crate::types::{PhpType, TypeEnv}; use super::super::super::Checker; -use super::merge_null_coalesce_result_type; +use super::{merge_match_arm_result_type, merge_null_coalesce_result_type}; impl Checker { /// Infers the type of an expression while tracking assignment effects through the environment. @@ -99,6 +99,7 @@ impl Checker { if matches!(op, BinOp::And | BinOp::Or) { let mut right_env = env.clone(); self.infer_type_with_assignment_effects(right, &mut right_env)?; + merge_array_storage_effects(env, &right_env); Ok(PhpType::Bool) } else { self.infer_type_with_assignment_effects(right, env)?; @@ -111,7 +112,10 @@ impl Checker { self.infer_type_with_assignment_effects(default, env)? } else { let mut default_env = env.clone(); - self.infer_type_with_assignment_effects(default, &mut default_env)? + let default_ty = + self.infer_type_with_assignment_effects(default, &mut default_env)?; + merge_array_storage_effects(env, &default_env); + default_ty }; let non_null_value = if Self::union_contains_void(&value_ty) { self.strip_void_from_union(&value_ty) @@ -130,6 +134,7 @@ impl Checker { } else { let mut default_env = env.clone(); self.infer_type_with_assignment_effects(default, &mut default_env)?; + merge_array_storage_effects(env, &default_env); } // Result type comes from the Mixed-aware short-ternary merge in `infer_type`. self.infer_type(expr, env) @@ -150,10 +155,12 @@ impl Checker { then_env.insert(guard.var.clone(), guard.then_ty); else_env.insert(guard.var, guard.else_ty); } - self.infer_type_with_assignment_effects(then_expr, &mut then_env)?; - self.infer_type_with_assignment_effects(else_expr, &mut else_env)?; - // Result type comes from the Mixed-aware ternary merge in `infer_type`. - self.infer_type(expr, env) + let then_ty = self.infer_type_with_assignment_effects(then_expr, &mut then_env)?; + merge_array_storage_effects(env, &then_env); + merge_array_storage_effects(&mut else_env, &then_env); + let else_ty = self.infer_type_with_assignment_effects(else_expr, &mut else_env)?; + merge_array_storage_effects(env, &else_env); + Ok(merge_match_arm_result_type(self, then_ty, else_ty)) } ExprKind::ArrayLiteral(elems) => { for elem in elems { @@ -180,10 +187,12 @@ impl Checker { self.infer_type_with_assignment_effects(condition, &mut arm_env)?; } self.infer_type_with_assignment_effects(result, &mut arm_env)?; + merge_array_storage_effects(env, &arm_env); } if let Some(default) = default { let mut default_env = env.clone(); self.infer_type_with_assignment_effects(default, &mut default_env)?; + merge_array_storage_effects(env, &default_env); } // Result type comes from the Mixed-aware match merge in `infer_type` // (assignment effects must not reintroduce the Str-absorbing syntactic join). @@ -319,10 +328,19 @@ impl Checker { self.infer_type_with_assignment_effects(property, env)?; self.infer_type(expr, env) } - ExprKind::MethodCall { object, args, .. } - | ExprKind::NullsafeMethodCall { object, args, .. } => { + ExprKind::MethodCall { + object, + method, + args, + } + | ExprKind::NullsafeMethodCall { + object, + method, + args, + } => { self.infer_type_with_assignment_effects(object, env)?; let expanded_args = crate::types::call_args::expand_static_assoc_spread_args(args); + self.promote_pdo_binding_ref_storage(object, method, &expanded_args, env)?; for arg in &expanded_args { self.infer_type_with_assignment_effects(arg, env)?; } @@ -411,6 +429,40 @@ impl Checker { .is_some_and(callable_target_is_preg_replace_callback) } + /// Widens PDO binding destinations before by-reference signature validation. + fn promote_pdo_binding_ref_storage( + &mut self, + object: &Expr, + method: &str, + args: &[Expr], + env: &mut TypeEnv, + ) -> Result<(), CompileError> { + let object_ty = self.infer_type(object, env)?; + if !type_may_be_pdo_statement(&object_ty) { + return Ok(()); + } + let parameter_name = match crate::names::php_symbol_key(method).as_str() { + "bindparam" => "variable", + "bindcolumn" => "var", + _ => return Ok(()), + }; + let argument = args.iter().enumerate().find_map(|(index, arg)| match &arg.kind { + ExprKind::NamedArg { name, value } if name == parameter_name => Some(value.as_ref()), + ExprKind::NamedArg { .. } => None, + _ if index == 1 => Some(arg), + _ => None, + }); + let Some(Expr { + kind: ExprKind::Variable(name), + .. + }) = argument + else { + return Ok(()); + }; + env.insert(name.clone(), PhpType::Mixed); + Ok(()) + } + /// Marks the active statement stream as having crossed eval and widens local facts. fn mark_eval_barrier(&mut self, env: &mut TypeEnv) { self.eval_barrier_active = true; @@ -428,6 +480,29 @@ impl Checker { } } +/// Returns whether a receiver type may contain a PDOStatement instance. +fn type_may_be_pdo_statement(ty: &PhpType) -> bool { + match ty { + PhpType::Object(class) => class.trim_start_matches('\\') == "PDOStatement", + PhpType::Union(members) => members.iter().any(type_may_be_pdo_statement), + _ => false, + } +} + +/// Merges array layout conversions from a conditional expression arm into its outer environment. +fn merge_array_storage_effects(env: &mut TypeEnv, branch_env: &TypeEnv) { + let converted = branch_env + .iter() + .filter_map(|(name, branch_ty)| { + let converted = crate::types::array_storage_conversion(env.get(name), branch_ty)?; + Some((name.clone(), converted)) + }) + .collect::>(); + for (name, converted) in converted { + env.insert(name, converted); + } +} + /// Returns true when a first-class callable target is PHP `preg_replace_callback`. fn callable_target_is_preg_replace_callback(target: &CallableTarget) -> bool { matches!( diff --git a/src/types/checker/inference/expr/mod.rs b/src/types/checker/inference/expr/mod.rs index 6b99525e44..0ab7a704a1 100644 --- a/src/types/checker/inference/expr/mod.rs +++ b/src/types/checker/inference/expr/mod.rs @@ -228,7 +228,8 @@ fn is_valid_string_offset_index(index: &Expr, idx_ty: &PhpType) -> bool { /// `Never`-typed arms (`throw`, normalized at the call site) defer to the /// other arm's type, `Void`-typed arms (checker `null`) keep the merge /// nullable so the null arm's value survives return-type-driven coercion. -/// Array pairs widen their element types while keeping the array container. +/// Array pairs widen their element types while keeping the array container; array/false pairs +/// retain the declared PHP sentinel union instead of collapsing to bare Mixed. /// Object pairs, including supported `false`/null sentinels, retain a normalized /// union so declared object-union returns and member validation remain precise; /// every other heterogeneous pair widens to `Mixed` so each arm's runtime value @@ -252,6 +253,13 @@ fn merge_match_arm_result_type(checker: &Checker, acc: PhpType, next: PhpType) - if let Some(merged) = merge_array_branch_types(&acc, &next) { return merged; } + if matches!(acc, PhpType::Array(_) | PhpType::AssocArray { .. }) + && next == PhpType::False + || matches!(next, PhpType::Array(_) | PhpType::AssocArray { .. }) + && acc == PhpType::False + { + return checker.normalize_union_type(vec![acc, next]); + } if object_union_match_arm_type(&acc) && object_union_match_arm_type(&next) { return merge_object_union_match_arm_types(checker, acc, next); } diff --git a/src/types/checker/inference/objects/constructors.rs b/src/types/checker/inference/objects/constructors.rs index d8b93f32da..26ae6ba224 100644 --- a/src/types/checker/inference/objects/constructors.rs +++ b/src/types/checker/inference/objects/constructors.rs @@ -102,8 +102,8 @@ impl Checker { .map(String::as_str) .unwrap_or(class_name.as_str()); if !self.can_access_member(declaring_class, visibility) - && !self - .can_construct_internal_iterator_from_builtin_get_iterator(&class_name) + && !self.can_construct_internal_iterator_from_builtin_get_iterator(&class_name) + && !self.can_construct_pdo_row_from_prelude_fetch(&class_name) { return Err(CompileError::new( expr.span, @@ -220,6 +220,13 @@ impl Checker { && self.current_method.as_deref() == Some(get_iterator_key.as_str()) } + /// Allows PDOStatement::fetch() to allocate the private internal PDORow view. + fn can_construct_pdo_row_from_prelude_fetch(&self, class_name: &str) -> bool { + class_name == "PDORow" + && self.current_class.as_deref() == Some("PDOStatement") + && self.current_method.as_deref() == Some(php_symbol_key("fetch").as_str()) + } + /// Validates constructor arguments for reflection owner classes. /// /// Extracts the reflected class/member metadata from literal arguments and diff --git a/src/types/checker/inference/objects/methods.rs b/src/types/checker/inference/objects/methods.rs index d295b5e401..888b97502f 100644 --- a/src/types/checker/inference/objects/methods.rs +++ b/src/types/checker/inference/objects/methods.rs @@ -370,7 +370,9 @@ impl Checker { .get(&method_key) .map(String::as_str) .unwrap_or(class_name); - if !self.can_access_member(declaring_class, visibility) { + if !self.can_access_member(declaring_class, visibility) + && !self.can_access_pdo_prelude_internal_method(class_name, &method_key) + { // PHP raises this as a catchable `Error` at runtime instead of a // compile-time rejection. Record the throw site so EIR lowering // emits the throw sequence, and continue with the declared return @@ -528,17 +530,24 @@ impl Checker { } } } - if method_variadic_tail_needs_iterable( - &normalized_args, - sig, - regular_param_count, - env, - ) && !method_variadic_param_is_by_ref(sig) + let variadic_is_declared = declared_flags + .get(regular_param_count) + .copied() + .unwrap_or(false); + if !variadic_is_declared + && method_variadic_tail_needs_iterable( + &normalized_args, + sig, + regular_param_count, + env, + ) + && !method_variadic_param_is_by_ref(sig) { if let Some((_, variadic_ty)) = sig.params.last_mut() { *variadic_ty = PhpType::Iterable; } - } else if sig.variadic.is_some() + } else if !variadic_is_declared + && sig.variadic.is_some() && arg_types.len() > regular_param_count && !method_variadic_param_is_by_ref(sig) { @@ -858,7 +867,9 @@ impl Checker { .get(&method_key) .map(String::as_str) .unwrap_or(class_name); - if !self.can_access_member(declaring_class, visibility) { + if !self.can_access_member(declaring_class, visibility) + && !self.can_access_pdo_exception_internal_factory(class_name, method) + { return Err(CompileError::new( expr.span, &format!( @@ -1094,17 +1105,24 @@ impl Checker { } } } - if method_variadic_tail_needs_iterable( - &normalized_args, - sig, - regular_param_count, - env, - ) && !method_variadic_param_is_by_ref(sig) + let variadic_is_declared = static_declared_flags + .get(regular_param_count) + .copied() + .unwrap_or(false); + if !variadic_is_declared + && method_variadic_tail_needs_iterable( + &normalized_args, + sig, + regular_param_count, + env, + ) + && !method_variadic_param_is_by_ref(sig) { if let Some((_, variadic_ty)) = sig.params.last_mut() { *variadic_ty = PhpType::Iterable; } - } else if sig.variadic.is_some() + } else if !variadic_is_declared + && sig.variadic.is_some() && arg_types.len() > regular_param_count && !method_variadic_param_is_by_ref(sig) { @@ -1164,7 +1182,12 @@ impl Checker { } } } - if sig.variadic.is_some() + let variadic_is_declared = instance_declared_flags + .get(regular_param_count) + .copied() + .unwrap_or(false); + if !variadic_is_declared + && sig.variadic.is_some() && arg_types.len() > regular_param_count && !method_variadic_param_is_by_ref(sig) { @@ -1200,6 +1223,29 @@ impl Checker { }) .cloned() } + + /// Allows tightly-scoped private helper calls between compiler-generated PDO prelude classes. + fn can_access_pdo_prelude_internal_method(&self, class_name: &str, method_key: &str) -> bool { + let lazy_row_refresh = class_name == "PDORow" + && method_key == "__elephcrefresh" + && self.current_class.as_deref() == Some("PDOStatement") + && self.current_method.as_deref() == Some("fetch"); + let pgsql_notice_drain = matches!(class_name, "PDO" | "Pdo\\Pgsql") + && method_key == "__elephcdrainpgsqlnotices" + && matches!( + (self.current_class.as_deref(), self.current_method.as_deref()), + (Some("PDOStatement"), Some("execute")) + | (Some("Pdo\\Pgsql"), Some("exec" | "query")) + ); + lazy_row_refresh || pgsql_notice_drain + } + + /// Allows compiler-generated PDO methods to construct exceptions with private driver state. + fn can_access_pdo_exception_internal_factory(&self, class_name: &str, method: &str) -> bool { + class_name == "PDOException" + && php_symbol_key(method) == "__elephcfromerrorinfo" + && matches!(self.current_class.as_deref(), Some("PDO" | "PDOStatement")) + } } /// Returns true when a method variadic parameter must keep runtime key information. diff --git a/src/types/checker/schema/classes/interfaces.rs b/src/types/checker/schema/classes/interfaces.rs index 1bf3106941..51ad6183c1 100644 --- a/src/types/checker/schema/classes/interfaces.rs +++ b/src/types/checker/schema/classes/interfaces.rs @@ -19,7 +19,8 @@ use crate::types::{PhpType, PropertyHookContract}; use super::super::super::Checker; use super::super::validation::{ - declared_return_type_compatible, late_static_return_compatible, + declared_return_type_compatible, is_pdo_exception_get_code_contract, + late_static_return_compatible, validate_signature_compatibility, }; use super::state::ClassBuildState; @@ -321,7 +322,16 @@ fn validate_static_interface_method( .map(|method| method.span) .unwrap_or_else(crate::span::Span::dummy), )?; - let return_compatible = late_static_compatible.unwrap_or_else(|| { + let contract_owner = state + .method_declaring_classes + .get(method_name) + .map(String::as_str) + .unwrap_or(&class.name); + let return_compatible = is_pdo_exception_get_code_contract( + contract_owner, + method_name, + &actual_sig.return_type, + ) || late_static_compatible.unwrap_or_else(|| { interface_self_return_conforms( checker, class, @@ -534,7 +544,20 @@ fn validate_interface_method( .map(|method| method.span) .unwrap_or_else(crate::span::Span::dummy), )?; - let return_compatible = late_static_compatible.unwrap_or_else(|| { + let contract_owner = state + .method_declaring_classes + .get(method_name) + .map(String::as_str) + .unwrap_or(&class.name); + let return_compatible = (is_pdo_exception_get_code_contract( + contract_owner, + method_name, + &actual_sig.return_type, + ) || is_pdo_exception_get_code_contract( + &class.name, + method_name, + &actual_sig.return_type, + )) || late_static_compatible.unwrap_or_else(|| { interface_self_return_conforms( checker, class, diff --git a/src/types/checker/schema/classes/methods.rs b/src/types/checker/schema/classes/methods.rs index e40559625e..c425e4731c 100644 --- a/src/types/checker/schema/classes/methods.rs +++ b/src/types/checker/schema/classes/methods.rs @@ -240,15 +240,21 @@ fn apply_instance_method( method, )); } - if let Some(parent_visibility) = state.method_visibilities.get(&method_key) { - if visibility_rank(&method.visibility) < visibility_rank(parent_visibility) { - return Err(CompileError::new( - method.span, - &format!( - "Cannot reduce visibility when overriding method: {}::{}", - class.name, method.name - ), - )); + // PHP exempts constructors from ordinary override compatibility: a child may + // deliberately reduce constructor visibility (PDOStatement subclasses are the + // canonical internal-API example). Signature validation already carries the same + // `__construct` exemption; keep visibility validation aligned with it. + if method_key != "__construct" { + if let Some(parent_visibility) = state.method_visibilities.get(&method_key) { + if visibility_rank(&method.visibility) < visibility_rank(parent_visibility) { + return Err(CompileError::new( + method.span, + &format!( + "Cannot reduce visibility when overriding method: {}::{}", + class.name, method.name + ), + )); + } } } if let Some(parent_sig) = state.method_sigs.get(&method_key) { diff --git a/src/types/checker/schema/classes/state.rs b/src/types/checker/schema/classes/state.rs index 205bad2d64..e8a48bcece 100644 --- a/src/types/checker/schema/classes/state.rs +++ b/src/types/checker/schema/classes/state.rs @@ -144,6 +144,16 @@ impl ClassBuildState { )) }) .collect::, CompileError>>()?, + constant_deprecations: class + .constants + .iter() + .filter_map(|constant| { + crate::types::checker::schema::validation::extract_deprecation( + &constant.attributes, + ) + .map(|reason| (constant.name.clone(), reason)) + }) + .collect(), constant_types: class .constants .iter() diff --git a/src/types/checker/schema/enums.rs b/src/types/checker/schema/enums.rs index 08d1d04be6..83035f76c2 100644 --- a/src/types/checker/schema/enums.rs +++ b/src/types/checker/schema/enums.rs @@ -464,6 +464,15 @@ pub(crate) fn insert_enum_metadata( is_readonly_class: true, allow_dynamic_properties: false, constants, + constant_deprecations: user_constants + .iter() + .filter_map(|constant| { + crate::types::checker::schema::validation::extract_deprecation( + &constant.attributes, + ) + .map(|reason| (constant.name.clone(), reason)) + }) + .collect(), constant_types, constant_visibilities, final_constants, diff --git a/src/types/checker/schema/validation.rs b/src/types/checker/schema/validation.rs index a6fd7077e8..47b2e6c5b9 100644 --- a/src/types/checker/schema/validation.rs +++ b/src/types/checker/schema/validation.rs @@ -307,6 +307,22 @@ pub(crate) fn declared_return_type_compatible( matches!(actual, PhpType::Never) || checker.type_accepts(expected, actual) } +/// Returns true for PDO's internal SQLSTATE-aware widening of `Exception::getCode()`. +pub(crate) fn is_pdo_exception_get_code_contract( + class_name: &str, + method_name: &str, + return_type: &PhpType, +) -> bool { + let PhpType::Union(types) = return_type else { + return false; + }; + class_name.trim_start_matches('\\') == "PDOException" + && php_symbol_key(method_name) == "getcode" + && types.len() == 2 + && types.contains(&PhpType::Str) + && types.contains(&PhpType::Int) +} + /// Checks a preserved late-static parent/interface return against a child declaration. /// /// A concrete class name cannot replace `static`: that would become unsound for further @@ -381,7 +397,11 @@ pub(crate) fn validate_override_signature( class_name, method.span, )?; - let return_compatible = late_static_compatible.unwrap_or_else(|| { + let return_compatible = is_pdo_exception_get_code_contract( + class_name, + &method.name, + &child_sig.return_type, + ) || late_static_compatible.unwrap_or_else(|| { declared_return_type_compatible( checker, &parent_sig.return_type, diff --git a/src/types/checker/stmt_check.rs b/src/types/checker/stmt_check.rs index dbd6d32f1d..ac7f061307 100644 --- a/src/types/checker/stmt_check.rs +++ b/src/types/checker/stmt_check.rs @@ -45,6 +45,9 @@ impl Checker { ) -> Result<(), CompileError> { match &stmt.kind { StmtKind::Synthetic(stmts) => { + if self.check_empty_indexed_nested_append(stmts, env)? { + return Ok(()); + } for stmt in stmts { self.check_stmt(stmt, env)?; } diff --git a/src/types/checker/stmt_check/assignments.rs b/src/types/checker/stmt_check/assignments.rs index d4c817db6d..a525b355ec 100644 --- a/src/types/checker/stmt_check/assignments.rs +++ b/src/types/checker/stmt_check/assignments.rs @@ -15,12 +15,78 @@ mod properties_null_coalesce; mod static_properties; use crate::errors::CompileError; -use crate::parser::ast::{Expr, ExprKind, Stmt, StmtKind}; -use crate::types::TypeEnv; +use crate::parser::ast::{Expr, ExprKind, Stmt, StmtKind, NESTED_APPEND_TEMP_PREFIX}; +use crate::types::{normalized_array_key_type, PhpType, TypeEnv}; use super::super::Checker; impl Checker { + /// Type-checks the parser-generated `$array[$index][] = $value` sequence when + /// an empty indexed base needs its element type auto-vivified to an array. + /// + /// Returns `true` only after consuming a recognized synthetic suffix. All + /// other synthetic groups, associative keys, and already-typed bases remain + /// on the ordinary statement-by-statement path. + pub(crate) fn check_empty_indexed_nested_append( + &mut self, + body: &[Stmt], + env: &mut TypeEnv, + ) -> Result { + if body.len() < 3 { + return Ok(false); + } + let split = body.len() - 3; + let (prefix, triple) = body.split_at(split); + let (temp, base, index) = match &triple[0].kind { + StmtKind::Assign { name, value } + if name.starts_with(NESTED_APPEND_TEMP_PREFIX) => + { + match &value.kind { + ExprKind::ArrayAccess { array, index } => match &array.kind { + ExprKind::Variable(base) => (name.as_str(), base.as_str(), index.as_ref()), + _ => return Ok(false), + }, + _ => return Ok(false), + } + } + _ => return Ok(false), + }; + if !matches!( + &triple[1].kind, + StmtKind::ArrayPush { array, .. } if array == temp + ) || !matches!( + &triple[2].kind, + StmtKind::ArrayAssign { array, value, .. } + if array == base + && matches!(&value.kind, ExprKind::Variable(name) if name == temp) + ) { + return Ok(false); + } + if !matches!(env.get(base), Some(PhpType::Array(element)) if **element == PhpType::Never) { + return Ok(false); + } + + for stmt in prefix { + self.check_stmt(stmt, env)?; + } + let index_type = self.infer_type_with_assignment_effects(index, env)?; + if normalized_array_key_type(index, index_type) != PhpType::Int { + return Ok(false); + } + + // PHP auto-vivifies the missing bucket as an empty indexed array. Seeding + // the parser's hidden read temporary with that shape lets the ordinary + // push checker infer `Array(T)` and the ordinary write-back checker infer + // the outer `Array(Array(T))` without weakening user-visible assignments. + env.insert( + temp.to_string(), + PhpType::Array(Box::new(PhpType::Never)), + ); + self.check_stmt(&triple[1], env)?; + self.check_stmt(&triple[2], env)?; + Ok(true) + } + /// Returns true when `name` is bound as a `foreach` loop key in the current /// scope. A foreach key is a boxed `Mixed` cell at runtime even when the /// checker types it as `Int`/`Str` from the source array, so an array write diff --git a/src/types/checker/stmt_check/assignments/properties.rs b/src/types/checker/stmt_check/assignments/properties.rs index 256a8cc430..eb2412a624 100644 --- a/src/types/checker/stmt_check/assignments/properties.rs +++ b/src/types/checker/stmt_check/assignments/properties.rs @@ -37,6 +37,13 @@ pub(super) fn check_property_assign( if let PhpType::Object(class_name) = &obj_ty { check_object_property_write(checker, object, class_name, property, value, &val_ty, span)?; refine_object_property_type(checker, class_name, property, &val_ty); + } else if let Some(class_name) = checker.union_single_object_class(&obj_ty) { + // A factory-style `Object|false` receiver still targets the one object + // class when the runtime value is an object. Validate writes against that + // class so readonly/visibility/type rules are not silently bypassed merely + // because the success value has not yet been narrowed with instanceof. + check_object_property_write(checker, object, &class_name, property, value, &val_ty, span)?; + refine_object_property_type(checker, &class_name, property, &val_ty); } if let PhpType::Pointer(Some(class_name)) = &obj_ty { check_pointer_property_write(checker, class_name, property, &val_ty, span)?; @@ -232,6 +239,14 @@ fn check_object_property_write( .unwrap_or(PhpType::Int); let readonly_non_null_coalesce_keep = null_coalesce_property_keeps_non_null(object, property, value, &expected_ty); + let internal_pdo_statement_initializer = checker + .current_method + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case("__elephcInitialize")) + && class_info + .property_declaring_classes + .get(property) + .is_some_and(|owner| owner.trim_start_matches('\\').eq_ignore_ascii_case("PDOStatement")); if class_info.readonly_properties.contains(property) && !(checker.current_class.as_deref() == class_info @@ -239,6 +254,7 @@ fn check_object_property_write( .get(property) .map(String::as_str) && checker.current_method.as_deref() == Some("__construct")) + && !internal_pdo_statement_initializer && !readonly_non_null_coalesce_keep { // PHP raises this as a catchable `Error` at runtime instead of a diff --git a/src/types/checker/stmt_check/narrowing.rs b/src/types/checker/stmt_check/narrowing.rs index 5f5d30cd3f..6e5e09be15 100644 --- a/src/types/checker/stmt_check/narrowing.rs +++ b/src/types/checker/stmt_check/narrowing.rs @@ -6,8 +6,9 @@ //! - `crate::types::checker::stmt_check::control_flow` when checking `StmtKind::If`. //! //! Key details: -//! - Recognizes `is_int`/`is_float`/`is_string`/`is_bool($var)` (and aliases) and `$var instanceof -//! Class` guards, optionally negated with a leading `!`. Narrowing is applied to each clause in an +//! - Recognizes scalar, null, array, and callable `is_*($var)` predicates (and aliases), +//! `$var instanceof Class`, and strict null/false comparisons, optionally negated. Narrowing is +//! applied to each clause in an //! if/elseif*/else chain (each subsequent clause, and the else, see the accumulated complement //! from previous guards). For a chain with no else where *every* clause body cannot fall through //! to the following statement — via `src/termination.rs`'s structural analysis @@ -38,6 +39,24 @@ pub(crate) struct GuardNarrowing { pub else_ty: PhpType, } +/// Describes an exact guard type or the element-agnostic array family. +enum GuardTarget { + /// An exact scalar, null, callable, or object target. + Exact(PhpType), + /// Any indexed or associative array, regardless of its element types. + AnyArray, +} + +impl GuardTarget { + /// Returns the conservative type used when the current type has no matching union member. + fn fallback_type(&self) -> PhpType { + match self { + Self::Exact(ty) => ty.clone(), + Self::AnyArray => PhpType::Mixed, + } + } +} + impl Checker { /// Detects a type-predicate guard in an `if`/ternary condition and computes the then/else /// narrowing for the guarded binding against the current environment. Handles the scalar @@ -52,13 +71,14 @@ impl Checker { condition: &Expr, env: &TypeEnv, ) -> Result, CompileError> { - let (cond, negated) = match &condition.kind { + let (cond, prefix_negated) = match &condition.kind { ExprKind::Not(inner) => (inner.as_ref(), true), _ => (condition, false), }; - let Some((receiver, target)) = guard_receiver_and_type(cond) else { + let Some((receiver, target, comparison_negated)) = guard_receiver_and_target(cond) else { return Ok(None); }; + let negated = prefix_negated ^ comparison_negated; let Some(key) = Self::guard_env_key(receiver) else { return Ok(None); }; @@ -151,29 +171,28 @@ impl Checker { } /// Narrows `current` to the guard-true type. Inside the branch the guard guarantees the target, - /// so `Mixed` and any incompatible concrete type become `target`; a `Union` keeps only its - /// matching members (falling back to `target` if none match); a concrete type already matching - /// the guard is kept as-is (preserving a more specific class for `instanceof`). - fn narrow_to(&self, current: &PhpType, target: &PhpType) -> PhpType { + /// so `Mixed` and incompatible concrete types use the target fallback; a `Union` keeps matching + /// members; a concrete match is preserved, including its array element or object class type. + fn narrow_to(&self, current: &PhpType, target: &GuardTarget) -> PhpType { match current { PhpType::Union(members) => { let kept: Vec = members.iter().filter(|m| guard_matches(m, target)).cloned().collect(); if kept.is_empty() { - target.clone() + target.fallback_type() } else { self.normalize_union_type(kept) } } _ if guard_matches(current, target) => current.clone(), - _ => target.clone(), + _ => target.fallback_type(), } } /// Narrows `current` to the subset incompatible with `target` (the guard-false type): a `Union` /// drops its matching members, while `Mixed` and concrete types are returned unchanged (the /// complement of `Mixed` is not representable). An empty result falls back to `current`. - fn narrow_complement(&self, current: &PhpType, target: &PhpType) -> PhpType { + fn narrow_complement(&self, current: &PhpType, target: &GuardTarget) -> PhpType { match current { PhpType::Union(members) => { let kept: Vec = @@ -228,37 +247,42 @@ impl Checker { } } -/// Extracts the guarded receiver expression and the target type from a (non-negated) guard -/// expression. Recognizes the scalar `is_*` predicates, `is_null`, `instanceof `, and -/// `=== false` / `=== null`. The receiver may be any expression here — `guard_env_key` decides -/// which receivers narrowing can actually key (variables and simple property accesses). -fn guard_receiver_and_type(cond: &Expr) -> Option<(&Expr, PhpType)> { +/// Extracts the guarded receiver, target, and comparison negation from a guard expression. +fn guard_receiver_and_target(cond: &Expr) -> Option<(&Expr, GuardTarget, bool)> { match &cond.kind { ExprKind::FunctionCall { name, args } if args.len() == 1 => { let target = match name.as_str().to_ascii_lowercase().as_str() { - "is_int" | "is_integer" | "is_long" => PhpType::Int, - "is_float" | "is_double" | "is_real" => PhpType::Float, - "is_string" => PhpType::Str, - "is_bool" => PhpType::Bool, + "is_int" | "is_integer" | "is_long" => GuardTarget::Exact(PhpType::Int), + "is_float" | "is_double" | "is_real" => GuardTarget::Exact(PhpType::Float), + "is_string" => GuardTarget::Exact(PhpType::Str), + "is_bool" => GuardTarget::Exact(PhpType::Bool), // `is_null($x)`: same narrowing as `$x === null` — elephc models a `?T` value's // null as Void, so the complement strips it (`if (is_null($x)) { throw; }` leaves // ?int as int on the fall-through path). - "is_null" => PhpType::Void, + "is_null" => GuardTarget::Exact(PhpType::Void), + "is_callable" => GuardTarget::Exact(PhpType::Callable), + "is_array" => GuardTarget::AnyArray, _ => return None, }; - Some((&args[0], target)) + Some((&args[0], target, false)) } ExprKind::InstanceOf { value, target } => { let InstanceOfTarget::Name(class) = target else { return None; }; - Some((value, PhpType::Object(class.as_str().to_string()))) + Some(( + value, + GuardTarget::Exact(PhpType::Object(class.as_str().to_string())), + false, + )) } // `$var === false` / `false === $var`: narrow to the literal False subtype in the // then-branch; the else-branch strips only that member (e.g. int|false → int) while a full // `bool` member remains. Enables the common // `if ($x === false) { throw; } return $x;` guard (ward-http StreamGuards::requireInt etc.). - ExprKind::BinaryOp { left, op: BinOp::StrictEq, right } => { + ExprKind::BinaryOp { left, op, right } + if matches!(op, BinOp::StrictEq | BinOp::StrictNotEq) => + { let (receiver, lit) = match (&left.kind, &right.kind) { (ExprKind::Variable(_) | ExprKind::PropertyAccess { .. }, _) => { (left.as_ref(), &right.kind) @@ -269,10 +293,18 @@ fn guard_receiver_and_type(cond: &Expr) -> Option<(&Expr, PhpType)> { _ => return None, }; match lit { - ExprKind::BoolLiteral(false) => Some((receiver, PhpType::False)), + ExprKind::BoolLiteral(false) => Some(( + receiver, + GuardTarget::Exact(PhpType::False), + matches!(op, BinOp::StrictNotEq), + )), // `$x === null`: strip the null-ish member (elephc models a `?T` value's null as // Void), e.g. `?self` / self|null → self after `if ($x === null) { throw; }`. - ExprKind::Null => Some((receiver, PhpType::Void)), + ExprKind::Null => Some(( + receiver, + GuardTarget::Exact(PhpType::Void), + matches!(op, BinOp::StrictNotEq), + )), _ => None, } } @@ -280,13 +312,14 @@ fn guard_receiver_and_type(cond: &Expr) -> Option<(&Expr, PhpType)> { } } -/// Returns true when a union member is compatible with a guard target, used to keep (then) or drop -/// (else) members. Scalar targets require an exact variant match; an `Object` target matches an -/// object member with the same class name (inheritance-aware narrowing is left for the future). -fn guard_matches(member: &PhpType, target: &PhpType) -> bool { - match (member, target) { - (PhpType::Object(member_class), PhpType::Object(target_class)) => member_class == target_class, - (PhpType::False, PhpType::Bool) => true, - _ => member == target, +/// Returns whether a union member matches the exact or array-family guard target. +fn guard_matches(member: &PhpType, target: &GuardTarget) -> bool { + match target { + GuardTarget::AnyArray => matches!(member, PhpType::Array(_) | PhpType::AssocArray { .. }), + GuardTarget::Exact(PhpType::Object(target_class)) => { + matches!(member, PhpType::Object(member_class) if member_class == target_class) + } + GuardTarget::Exact(PhpType::Bool) => matches!(member, PhpType::Bool | PhpType::False), + GuardTarget::Exact(target) => member == target, } } diff --git a/src/types/mod.rs b/src/types/mod.rs index fc8f2c0aae..2f6daae582 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -14,6 +14,8 @@ pub mod checker; pub mod traits; /// Array key type inference, normalization, and PHP integer/string coercion rules. mod array_keys; +/// Array storage-representation conversions shared by checking and lowering. +mod array_storage; /// PHP array extension integer constants. pub(crate) mod array_constants; /// Call argument planning: named, positional, and spread semantics. @@ -53,6 +55,7 @@ pub(crate) use array_keys::{ normalized_array_key_type, parse_php_string_offset_literal, static_array_key_forces_hash_storage, }; +pub(crate) use array_storage::{array_storage_conversion, join_array_storage_conversion}; pub use ffi::{ctype_stack_size, ctype_to_php_type, packed_type_size}; pub use model::{PhpType, TypeEnv}; pub(crate) use return_alias::{ diff --git a/src/types/schema.rs b/src/types/schema.rs index a60840b144..1c143941e9 100644 --- a/src/types/schema.rs +++ b/src/types/schema.rs @@ -266,6 +266,9 @@ pub struct ClassInfo { /// User-declared class constants (PHP 7.1+). Maps the constant name to /// its value expression — codegen inlines the literal at access time. pub constants: HashMap, + /// Deprecation reason for class constants carrying `#[\Deprecated]`, keyed + /// by the case-sensitive constant name. An empty string means no reason. + pub constant_deprecations: HashMap, /// PHP 8.3 declared types for constants declared directly on this class-like symbol. pub constant_types: HashMap, /// Class constant visibilities keyed by case-sensitive constant name. diff --git a/src/types/signatures.rs b/src/types/signatures.rs index 7c7b4d6349..73ac283edd 100644 --- a/src/types/signatures.rs +++ b/src/types/signatures.rs @@ -39,6 +39,34 @@ pub struct FunctionSig { pub deprecation: Option, } +impl FunctionSig { + /// Returns whether the CALLEE's frame owns a reference to by-value parameter `index`. + /// + /// True exactly when the parameter is by-value and its CODEGEN REPR is an array or an + /// associative array — which is precisely the set `privatize_container_param` re-binds to an + /// owning shadow slot on function entry, giving PHP its by-value array semantics. + /// + /// The repr matters, not the surface type: `iterable` keeps its own runtime shape (a raw heap + /// pointer dispatched on the heap-kind tag), so an `iterable` parameter is NOT privatized and + /// the callee can still hand its argument's payload straight back. The caller must keep its + /// pass-through alias guard for those, or it frees a value the result still points at. + /// + /// Deliberately a pure function of the signature, so the callee (which privatizes) and the + /// caller (which must then release its owning-temporary argument instead of suppressing it) + /// can never disagree. + pub fn param_is_callee_owned(&self, index: usize) -> bool { + if self.ref_params.get(index).copied().unwrap_or(false) { + return false; + } + self.params.get(index).is_some_and(|(_, php_type)| { + matches!( + php_type.codegen_repr(), + PhpType::Array(_) | PhpType::AssocArray { .. } + ) + }) + } +} + /// Upgrades a variadic signature for use as a first-class callable. /// /// If the variadic parameter is not already typed as `Array`, upgrades it to diff --git a/src/types/warnings/expr_reads.rs b/src/types/warnings/expr_reads.rs index 245aa24e86..de69a58bb7 100644 --- a/src/types/warnings/expr_reads.rs +++ b/src/types/warnings/expr_reads.rs @@ -12,7 +12,8 @@ use crate::errors::CompileWarning; use crate::parser::ast::{Expr, ExprKind, InstanceOfTarget, Stmt, StmtKind}; use super::scope_usage::{ - ScopeUsage, analyze_function_like_scope, analyze_method_scope, collect_free_reads_in_function_like, + ScopeUsage, analyze_closure_scope, analyze_function_like_scope, analyze_method_scope, + collect_free_reads_in_function_like, }; /// Recursively collects variable read warnings by scanning an expression tree. @@ -179,6 +180,7 @@ pub(super) fn collect_expr_reads( variadic, body, captures, + capture_refs, is_arrow, .. } => { @@ -190,7 +192,14 @@ pub(super) fn collect_expr_reads( for name in captures { scope.read(name); } - analyze_function_like_scope(params, variadic.as_ref(), body, expr.span, warnings); + analyze_closure_scope( + params, + variadic.as_ref(), + body, + expr.span, + capture_refs, + warnings, + ); } ExprKind::NamedArg { value, .. } => collect_expr_reads(value, scope, warnings), ExprKind::PropertyAccess { object, .. } diff --git a/src/types/warnings/scope_usage.rs b/src/types/warnings/scope_usage.rs index 22976b122d..28f125fa06 100644 --- a/src/types/warnings/scope_usage.rs +++ b/src/types/warnings/scope_usage.rs @@ -86,6 +86,46 @@ pub(super) fn analyze_function_like_scope( body: &[Stmt], declaration_span: Span, warnings: &mut Vec, +) { + analyze_function_like_scope_with_reads( + params, + variadic, + body, + declaration_span, + &[], + warnings, + ); +} + +/// Analyzes a closure scope while treating by-reference captures as used output +/// storage, so assigning through a retained caller reference is not reported unused. +pub(super) fn analyze_closure_scope( + params: &[(String, Option, Option, bool)], + variadic: Option<&String>, + body: &[Stmt], + declaration_span: Span, + capture_refs: &[String], + warnings: &mut Vec, +) { + analyze_function_like_scope_with_reads( + params, + variadic, + body, + declaration_span, + capture_refs, + warnings, + ); +} + +/// Implements function-like warning analysis with a set of names that are +/// semantically used even when the body only writes them. +fn analyze_function_like_scope_with_reads( + params: &[(String, Option, Option, bool)], + variadic: Option<&String>, + body: &[Stmt], + declaration_span: Span, + preset_reads: &[String], + warnings: &mut Vec, ) { let mut scope = ScopeUsage::default(); for (name, _, _, is_ref) in params { @@ -97,6 +137,9 @@ pub(super) fn analyze_function_like_scope( if let Some(name) = variadic { scope.declare(name, declaration_span); } + for name in preset_reads { + scope.read(name); + } collect_scope_reads(body, &mut scope, warnings); for (name, span) in scope.declared { if !scope.reads.contains(&name) && !name.starts_with('_') { diff --git a/src/web_prelude.rs b/src/web_prelude.rs index 5e465c15d3..93bd8cf664 100644 --- a/src/web_prelude.rs +++ b/src/web_prelude.rs @@ -21,139 +21,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; use crate::parser::ast::{Program, StmtKind}; -mod usage; - -/// Maintained PHP minor selected for version-dependent compatibility behavior. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum PhpVersion { - /// PHP 8.2 compatibility. - Php82, - /// PHP 8.3 compatibility. - Php83, - /// PHP 8.4 compatibility. - Php84, - /// PHP 8.5 compatibility, the default and newest maintained profile. - #[default] - Php85, -} - -impl PhpVersion { - /// Every maintained profile, oldest first. - /// - /// This is the set `--php-version` ranges over, and therefore the set any claim about - /// "profile-independent behavior" quantifies over. Adding a profile here is what makes - /// `php_profile::sensitivity`'s invariance guards re-check themselves against it. - pub const ALL: &'static [Self] = &[Self::Php82, Self::Php83, Self::Php84, Self::Php85]; +pub use crate::php_version::PhpVersion; - /// Returns the `major.minor` spelling `--php-version` accepts for this profile. - /// - /// This is the profile's NAME, not a version string: `PHP_VERSION` reports `8.5.0` where - /// this reports `8.5` (see [`Self::version_string`] for the patch-is-zero rule). - pub const fn spelling(self) -> &'static str { - match self { - Self::Php82 => "8.2", - Self::Php83 => "8.3", - Self::Php84 => "8.4", - Self::Php85 => "8.5", - } - } - - /// Parses one of the maintained `major.minor` spellings. - /// - /// Derived from [`Self::ALL`] rather than a second hand-written list, so a new profile is - /// accepted by the CLI the moment it joins the set. - pub fn parse(value: &str) -> Option { - Self::ALL - .iter() - .copied() - .find(|profile| profile.spelling() == value) - } - - /// Returns PHP's numeric `PHP_VERSION_ID` representation for this profile. - /// - /// This is exactly `major() * 10000 + minor() * 100 + release()` — the reference - /// formula, verified against PHP 8.5.6 (`php -r 'echo PHP_VERSION_ID;'` → `80506` - /// for `8.5.6`). Because elephc pins [`Self::release`] to `0` (see - /// [`Self::version_string`]), the ids stay at the `.0` boundary of each profile. - /// `version_id_matches_components` below asserts the identity for every profile. - pub const fn version_id(self) -> u32 { - match self { - Self::Php82 => 80200, - Self::Php83 => 80300, - Self::Php84 => 80400, - Self::Php85 => 80500, - } - } - - /// Returns the `PHP_VERSION` / `phpversion()` string elephc reports for this profile. - /// - /// # THE VERSION RULE (and why the patch component is always `0`) - /// - /// elephc targets a PHP **language profile** selected by `--php-version` (8.2/8.3/8.4/8.5), - /// not a specific upstream patch release. There is no PHP 8.5.6 runtime inside a compiled - /// binary to report the patch level of, so the only honest patch component is the one that - /// names the profile itself: `8..0`. - /// - /// This is the SAME rule the OPcache surface already applies — - /// `opcache_get_configuration()['version']['version']` reports `8.5.0` where reference PHP - /// 8.5.6 reports `8.5.6` (documented in `docs/php/opcache.md`). Reporting `8.5.0` here keeps - /// the two surfaces from contradicting each other inside one binary, and it keeps - /// `PHP_VERSION_ID` ([`Self::version_id`]) — which already existed and already encoded the - /// `.0` boundary — consistent with the string rather than forcing a choice between them. - /// - /// Consequence for feature detection, which is what this surface is really for: - /// `PHP_VERSION_ID >= 80500` answers exactly the question `--php-version 8.5` answers, and - /// `version_compare(PHP_VERSION, '8.5', '>=')` agrees with it. The only thing a caller - /// cannot learn is a patch level elephc genuinely does not have. - pub const fn version_string(self) -> &'static str { - match self { - Self::Php82 => "8.2.0", - Self::Php83 => "8.3.0", - Self::Php84 => "8.4.0", - Self::Php85 => "8.5.0", - } - } - - /// Returns `PHP_MAJOR_VERSION` for this profile (always `8` across the maintained set). - pub const fn major(self) -> u32 { - self.version_id() / 10000 - } - - /// Returns `PHP_MINOR_VERSION` for this profile (`2`..=`5`). - pub const fn minor(self) -> u32 { - (self.version_id() / 100) % 100 - } - - /// Returns `PHP_RELEASE_VERSION` for this profile — always `0`, see [`Self::version_string`]. - pub const fn release(self) -> u32 { - self.version_id() % 100 - } - - /// Returns `PHP_EXTRA_VERSION` — always the empty string. - /// - /// Reference PHP uses this for suffixes such as `-dev` or `RC1`; a released 8.5.6 reports - /// `""` (verified). elephc's profiles are never pre-release, so `""` is exact, not a - /// divergence. - pub const fn extra_version(self) -> &'static str { - "" - } - - /// Returns the `zend_version()` string for this profile. - /// - /// The Zend Engine major track runs four behind PHP's (PHP 8.x ships Zend Engine 4.x; - /// reference PHP 8.5.6 reports `4.5.6`), and its minor moves with PHP's minor. elephc - /// therefore reports `4..0`, applying the same patch-is-`0` rule as - /// [`Self::version_string`] for the same reason: there is no engine build to have a patch - /// level. It is a *language-profile* claim, not a claim to be Zend. - pub const fn zend_version(self) -> &'static str { - match self { - Self::Php82 => "4.2.0", - Self::Php83 => "4.3.0", - Self::Php84 => "4.4.0", - Self::Php85 => "4.5.0", - } - } -} +mod usage; /// Returns the `PHP_SAPI` / `php_sapi_name()` string for an elephc compile mode. /// diff --git a/tests/codegen/callables/constants_and_system.rs b/tests/codegen/callables/constants_and_system.rs index d85423996a..b4ac575ed1 100644 --- a/tests/codegen/callables/constants_and_system.rs +++ b/tests/codegen/callables/constants_and_system.rs @@ -365,6 +365,20 @@ fn test_call_user_func_array_basic() { assert_eq!(out, "7"); } +/// Verifies a callable boxed behind `mixed` remains recognizable by `is_callable()`. +#[test] +fn test_is_callable_accepts_boxed_callable_descriptor() { + let out = compile_and_run( + r#"count_args(10, 20, 30); assert_eq!(out, "3"); } +/// Verifies an explicitly `mixed` method variadic remains heterogeneous across call sites +/// instead of being permanently specialized to the first call's element type. +#[test] +fn test_mixed_variadic_method_does_not_specialize_between_calls() { + let out = compile_and_run( + r#"count_args("first") . ":" . $collector->count_args([1, 2], 3.5); +"#, + ); + assert_eq!(out, "1:2"); +} + +/// Verifies associative-array COW cloning retains receiver-bound callable +/// descriptors. Later insertions must not free descriptors already stored under +/// earlier keys when their source locals leave scope. +#[test] +fn test_assoc_array_cow_clone_retains_callable_descriptors() { + let out = compile_and_run( + r#"twice(...); +$callbacks = ["twice" => $twice]; +$triple = $target->triple(...); +$callbacks["triple"] = $triple; +$quadruple = $target->quadruple(...); +$callbacks["quadruple"] = $quadruple; +unset($twice, $triple, $quadruple); +echo call_user_func_array($callbacks["twice"], [5]); +echo ":" . call_user_func_array($callbacks["triple"], [5]); +echo ":" . call_user_func_array($callbacks["quadruple"], [5]); +"#, + ); + assert_eq!(out, "10:15:20"); +} + /// Verifies a typed variadic on a closure collects its arguments. #[test] fn test_typed_variadic_closure() { diff --git a/tests/codegen/cli.rs b/tests/codegen/cli.rs index 96e6a821a8..8d73b35e72 100644 --- a/tests/codegen/cli.rs +++ b/tests/codegen/cli.rs @@ -251,6 +251,40 @@ echo "ok"; let _ = fs::remove_dir_all(&dir); } +/// Verifies cross-target `--emit-asm` stops before preparing a host-incompatible runtime object. +#[test] +fn test_cli_emit_asm_does_not_require_target_assembler() { + let dir = make_cli_test_dir("elephc_cli_emit_cross_target_asm"); + let php_path = dir.join("main.php"); + fs::write(&php_path, "getMessage(); +} +"#, + ); + assert_eq!(out, "Error|dynamic:payload"); +} + /// Verifies exception try catch same function. #[test] fn test_exception_try_catch_same_function() { diff --git a/tests/codegen/io/streams.rs b/tests/codegen/io/streams.rs index f2f907e45c..a2b9773777 100644 --- a/tests/codegen/io/streams.rs +++ b/tests/codegen/io/streams.rs @@ -5446,6 +5446,26 @@ fclose($f); assert_eq!(out, "a,b,c\n\"x,y\",z\n"); } +/// A user wrapper's negative `stream_write()` result is the runtime failure +/// sentinel and must surface from PHP `fwrite()` as boolean false, never integer +/// `-1`; successful writes remain integer byte counts. +#[test] +fn test_fwrite_user_wrapper_negative_result_is_false() { + let out = compile_and_run( + r#" value pairs in the same insertion order. +#[test] +fn test_strict_eq_assoc_arrays_order_sensitive() { + let out = compile_and_run( + r#" 1, "y" => 2] === ["x" => 1, "y" => 2]); +var_dump(["x" => 1, "y" => 2] === ["x" => 1, "y" => 3]); +var_dump(["x" => 1, "y" => 2] === ["x" => 1, "z" => 2]); +var_dump(["x" => 1, "y" => 2] === ["y" => 2, "x" => 1]); +"#, + ); + assert_eq!(out, "bool(true)\nbool(false)\nbool(false)\nbool(false)\n"); +} + +/// Regression: nested arrays compare recursively through `__rt_mixed_strict_eq` re-entering +/// `__rt_array_strict_eq`. +#[test] +fn test_strict_eq_nested_arrays() { + let out = compile_and_run( + r#" [1]]] === [["a" => [1]]]); +var_dump([["a" => [1]]] === [["a" => [2]]]); +"#, + ); + assert_eq!(out, "bool(true)\nbool(false)\nbool(true)\nbool(false)\n"); +} + +/// Regression: heterogeneous arrays (mixed element types, stored as boxed Mixed slots) compare +/// with full per-element type precision. +#[test] +fn test_strict_eq_heterogeneous_arrays() { + let out = compile_and_run( + r#" 1] !== ["a" => 1]); +"#, + ); + assert_eq!(out, "bool(true)\nbool(false)\nbool(false)\n"); +} diff --git a/tests/codegen/pdo.rs b/tests/codegen/pdo.rs index 5b5a89e7c6..49496c9354 100644 --- a/tests/codegen/pdo.rs +++ b/tests/codegen/pdo.rs @@ -15,6 +15,7 @@ //! live server) live in `tests/codegen/pdo_pgsql.rs` and are `#[ignore]`d. use crate::support::*; +use elephc::php_version::PhpVersion; /// `new PDO("sqlite::memory:")` opens a database and `exec()` + a SELECT through /// `fetch(PDO::FETCH_ASSOC)` round-trips a row keyed by column name. @@ -67,6 +68,24 @@ echo $sel->fetchColumn(); assert_eq!(out, "seven"); } +/// P2-f: `PDO::prepare("")` throws a `ValueError` before any driver call at all, +/// matching php-src's `zend_argument_must_not_be_empty_error`. +#[test] +fn test_pdo_prepare_empty_query_throws() { + let out = compile_and_run( + r#"prepare(""); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw"; +} +"#, + ); + assert_eq!(out, "threw"); +} + /// `FETCH_NUM` returns a 0-indexed numeric array. #[test] fn test_pdo_fetch_num() { @@ -128,6 +147,19 @@ echo gettype($o) . ":" . $o->{"0"} . ":" . $o->name; /// `FETCH_CLASS` creates the requested class and assigns matching columns to /// declared properties; `FETCH_INTO` fills an existing object instance. +/// +/// F-STMT-01: the class/object TARGET reaches the statement through +/// `setFetchMode()`, never through `fetch()`. php-src's stub is `fetch(int $mode = +/// PDO::FETCH_DEFAULT, int $cursorOrientation = PDO::FETCH_ORI_NEXT, int +/// $cursorOffset = 0)` — position 2 is an INT ORIENTATION — so the idiom this test +/// used to assert, `fetch(PDO::FETCH_CLASS, Row::class)`, is a TypeError on real PHP +/// 8.4. It was a fabricated `mixed $classOrObject` parameter, and the test locked it +/// in; both are gone. +/// +/// This still exercises a path the sibling `setFetchMode` test below does NOT: the +/// mode is passed EXPLICITLY to `fetch()` while only the target comes from +/// `setFetchMode()`, so it pins that an explicit `$mode` argument does not discard +/// the separately-configured target. #[test] fn test_pdo_fetch_class_and_fetch_into() { let out = compile_and_run( @@ -142,12 +174,14 @@ $db->exec("CREATE TABLE t (id INTEGER, name TEXT)"); $db->exec("INSERT INTO t VALUES (1, 'Ada'), (2, 'Bob')"); $stmt = $db->query("SELECT id, name FROM t ORDER BY id"); -$row = $stmt->fetch(PDO::FETCH_CLASS, Row::class); +$stmt->setFetchMode(PDO::FETCH_CLASS, Row::class); +$row = $stmt->fetch(PDO::FETCH_CLASS); echo (($row instanceof Row) ? "Row" : "not-row") . ":" . $row->id . ":" . $row->name; $stmt2 = $db->query("SELECT id, name FROM t WHERE id = 2"); $into = new Row(); -$same = $stmt2->fetch(PDO::FETCH_INTO, $into); +$stmt2->setFetchMode(PDO::FETCH_INTO, $into); +$same = $stmt2->fetch(PDO::FETCH_INTO); echo "|" . (($same === $into) ? "same" : "different") . ":" . $into->id . ":" . $into->name; "#, ); @@ -316,7 +350,7 @@ echo $row["id"] . ":" . $row["name"]; assert_eq!(out, "10:Ada"); } -/// `bindParam()` binds the current value of the passed variable. +/// `bindParam()` reads the referenced variable at each execute rather than at bind time. #[test] fn test_pdo_bind_param() { let out = compile_and_run( @@ -326,11 +360,63 @@ $db->exec("CREATE TABLE t (n INTEGER)"); $n = 42; $ins = $db->prepare("INSERT INTO t (n) VALUES (?)"); $ins->bindParam(1, $n, PDO::PARAM_INT); +$n = 43; +$ins->execute(); +$n = 44; $ins->execute(); -echo $db->query("SELECT n FROM t")->fetchColumn(); +$rows = $db->query("SELECT n FROM t ORDER BY rowid")->fetchAll(PDO::FETCH_COLUMN, 0); +echo $rows[0] . ":" . $rows[1]; "#, ); - assert_eq!(out, "42"); + assert_eq!(out, "43:44"); +} + +/// P1-c: `execute($params)` REPLACES prior `bindValue()`/`bindParam()` bindings +/// rather than layering `$params` on top of them (matching php-src, which +/// destroys and rebuilds `bound_params` from `$input_params`). Slot 2 is +/// pre-bound to a stale 99 via `bindValue()`; `execute([0 => 7])` only supplies +/// slot 1 (array key 0 -> the first `?`), so slot 2 must come back NULL +/// (unbound for this call) instead of the stale 99 a buggy merge would keep. +#[test] +fn test_pdo_execute_params_replaces_prior_binds() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a INTEGER, b INTEGER)"); +$ins = $db->prepare("INSERT INTO t (a, b) VALUES (?, ?)"); +$ins->bindValue(2, 99, PDO::PARAM_INT); +$ins->execute([0 => 7]); +$row = $db->query("SELECT a, b FROM t")->fetch(PDO::FETCH_ASSOC); +echo $row["a"] . ":" . ($row["b"] === null ? "null" : $row["b"]); +"#, + ); + assert_eq!(out, "7:null"); +} + +/// P2 (this slice): php-src's `pdo_stmt_bind_input_params` DESTROYS +/// `stmt->bound_params` and REBUILDS it from `$input_params`, so a LATER +/// no-arg `execute()` replays THAT array, not whatever `bindValue()`/ +/// `bindParam()` call preceded it. Slot 1 is pre-bound to a stale `"a"` via +/// `bindValue()`; `execute(["b"])` rebinds it to `"b"` for its own call AND +/// records `"b"` as the new replay bindings, so the immediately-following +/// no-arg `execute()` must insert `"b"` again — not replay the stale `"a"` +/// a buggy implementation (recording only the ORIGINAL bindValue() call) +/// would keep. +#[test] +fn test_pdo_execute_params_persists_as_new_bindings_for_next_execute() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (val TEXT)"); +$ins = $db->prepare("INSERT INTO t (val) VALUES (?)"); +$ins->bindValue(1, "a"); +$ins->execute(["b"]); +$ins->execute(); +$rows = $db->query("SELECT val FROM t ORDER BY rowid")->fetchAll(PDO::FETCH_NUM); +echo count($rows) . ":" . $rows[0][0] . ":" . $rows[1][0]; +"#, + ); + assert_eq!(out, "2:b:b"); } /// `setFetchMode()` sets the default mode used by an argument-less `fetch()` / @@ -515,6 +601,174 @@ foreach ($stmt as $v) { assert_eq!(out, "x;y;z;"); } +/// P2-d: `setFetchMode(PDO::FETCH_COLUMN, $n)` with a negative column index +/// throws a `ValueError` and leaves the statement's previous fetch mode +/// untouched, mirroring php-src's `pdo_stmt_setup_fetch_mode`. +#[test] +fn test_pdo_set_fetch_mode_negative_column_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$stmt = $db->query("SELECT id FROM t"); +try { + $stmt->setFetchMode(PDO::FETCH_COLUMN, -1); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw"; +} +// The prior default fetch mode (FETCH_BOTH, from the connection default) must +// still be in effect — the rejected call did not partially apply. +$row = $stmt->fetch(); +echo ":" . (isset($row["id"]) && isset($row[0]) ? "both" : "other"); +"#, + ); + assert_eq!(out, "threw:both"); +} + +/// P2-d: an out-of-range base fetch mode passed to `setFetchMode()` throws a +/// `ValueError` instead of silently behaving like `FETCH_BOTH`. +#[test] +fn test_pdo_set_fetch_mode_unknown_mode_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$stmt = $db->prepare("SELECT id FROM t"); +try { + $stmt->setFetchMode(999); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw"; +} +"#, + ); + assert_eq!(out, "threw"); +} + +/// P3: `setFetchMode(PDO::FETCH_COLUMN)` with no column argument throws — +/// mirroring php-src's `pdo_stmt_setup_fetch_mode`, which raises an +/// `ArgumentCountError` here (elephc has no `ArgumentCountError` class, so +/// this raises the closest `ValueError`, using php-src's exact message text). +#[test] +fn test_pdo_set_fetch_mode_column_missing_arg_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$stmt = $db->prepare("SELECT id FROM t"); +try { + $stmt->setFetchMode(PDO::FETCH_COLUMN); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:PDOStatement::setFetchMode() expects exactly 2 arguments for the fetch mode provided, 1 given" + ); +} + +/// P3: `setFetchMode(PDO::FETCH_CLASS)` with no class-name argument throws the +/// same way (php-src's ArgumentCountError says "at least 2" here, since a +/// third constructor-args argument is also optional). +#[test] +fn test_pdo_set_fetch_mode_class_missing_arg_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$stmt = $db->prepare("SELECT id FROM t"); +try { + $stmt->setFetchMode(PDO::FETCH_CLASS); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:PDOStatement::setFetchMode() expects at least 2 arguments for the fetch mode provided, 1 given" + ); +} + +/// P3: `setFetchMode(PDO::FETCH_INTO)` with no target-object argument throws. +#[test] +fn test_pdo_set_fetch_mode_into_missing_arg_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$stmt = $db->prepare("SELECT id FROM t"); +try { + $stmt->setFetchMode(PDO::FETCH_INTO); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:PDOStatement::setFetchMode() expects exactly 2 arguments for the fetch mode provided, 1 given" + ); +} + +/// P3: `setFetchMode(PDO::FETCH_FUNC)` is rejected — php-src's +/// `pdo_stmt_verify_mode` only allows `FETCH_FUNC` as `fetchAll()`'s first +/// argument, not `setFetchMode()`'s (same message both call sites use). +#[test] +fn test_pdo_set_fetch_mode_func_rejected() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$stmt = $db->prepare("SELECT id FROM t"); +try { + $stmt->setFetchMode(PDO::FETCH_FUNC); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:Can only use PDO::FETCH_FUNC in PDOStatement::fetchAll()" + ); +} + +/// P3: the negative-`FETCH_COLUMN`-index `ValueError` names the real +/// underlying parameter php-src's arginfo carries for this position — the +/// variadic `$args`, not elephc's own `$colno` parameter name (verified +/// against php-src's `pdo_stmt_setup_fetch_mode`, whose +/// `zend_argument_value_error(arg1_arg_num, ...)` resolves the name from +/// `setFetchMode(int $mode, mixed ...$args)`'s arginfo). +#[test] +fn test_pdo_set_fetch_mode_negative_column_message_names_args_param() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$stmt = $db->prepare("SELECT id FROM t"); +try { + $stmt->setFetchMode(PDO::FETCH_COLUMN, -1); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:PDOStatement::setFetchMode(): Argument #2 ($args) must be greater than or equal to 0" + ); +} + /// `getAttribute`/`setAttribute` round-trip `ATTR_ERRMODE`; the default mode is /// `ERRMODE_EXCEPTION` (2) and `ATTR_DRIVER_NAME` reports the SQLite driver. #[test] @@ -531,20 +785,40 @@ echo ":" . $db->getAttribute(PDO::ATTR_DRIVER_NAME); assert_eq!(out, "2:0:sqlite"); } -/// `ATTR_PERSISTENT` is accepted through constructor options and setAttribute(), -/// can be read back, and constructor-level truthy values opt into the -/// process-local DSN pool. +/// P1-h: `setAttribute(ATTR_ERRMODE, ...)` rejects a value that is not one of the +/// PDO::ERRMODE_* constants with a `ValueError`, and leaves the error mode +/// unchanged (still reads back as EXCEPTION == 2 afterward). +#[test] +fn test_pdo_set_attribute_errmode_rejects_invalid() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_ERRMODE, 42); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw"; +} +echo ":" . $db->getAttribute(PDO::ATTR_ERRMODE); +"#, + ); + assert_eq!(out, "threw:2"); +} + +/// `ATTR_PERSISTENT` is a constructor-only choice. A later `setAttribute()` is +/// rejected and cannot change the live handle's persistent status. #[test] fn test_pdo_persistent_attribute_round_trip() { let out = compile_and_run( r#" true]); echo $db->getAttribute(PDO::ATTR_PERSISTENT) ? "1" : "0"; -$db->setAttribute(PDO::ATTR_PERSISTENT, false); -echo ":" . ($db->getAttribute(PDO::ATTR_PERSISTENT) ? "1" : "0"); +$changed = $db->setAttribute(PDO::ATTR_PERSISTENT, false); +echo ":" . (($changed === false) ? "rejected" : "changed") + . ":" . ($db->getAttribute(PDO::ATTR_PERSISTENT) ? "1" : "0"); "#, ); - assert_eq!(out, "1:0"); + assert_eq!(out, "1:rejected:1"); } /// Constructor-level `ATTR_PERSISTENT` opens through the process-local DSN pool: @@ -672,6 +946,26 @@ echo $upd->rowCount() . ":" . $del->rowCount(); assert_eq!(out, "3:0"); } +/// P1-2: SQLite's `rowCount()` always reports `0` after a SELECT-style +/// (column-returning) statement — never the stale write count of a prior DML on +/// the same connection. Verified against a real PHP 8.5 CLI with the same +/// fixture (three INSERTs then a SELECT). +#[test] +fn test_pdo_sqlite_row_count_zero_after_select() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$db->exec("INSERT INTO t (id) VALUES (2)"); +$db->exec("INSERT INTO t (id) VALUES (3)"); +$stmt = $db->query("SELECT id FROM t"); +echo $stmt->rowCount(); +"#, + ); + assert_eq!(out, "0"); +} + /// An aliased import (`use PDO as Db;`) still injects the prelude and resolves to /// PDO. The program references PDO only through the alias, so prelude detection /// must inspect the import name — `new Db()` carries the alias, not "PDO". @@ -689,3 +983,5309 @@ echo $row["id"]; ); assert_eq!(out, "7"); } + +/// Verifies fresh connections/statements expose php-src's uninitialized error state, +/// while a successful prepare initializes only the owning connection to `"00000"`. +#[test] +fn test_pdo_error_state_is_uninitialized_before_first_operation() { + let out = compile_and_run( + r#"errorInfo(); +$stmt = $db->prepare("SELECT 1"); +$stmtInfo = $stmt->errorInfo(); +echo ($dbInfo[0] === "" ? "empty" : "set") . ":" . ($dbInfo[1] === null ? "n" : "x") . "|"; +echo ($stmt->errorCode() === null ? "null" : "set") . ":" . ($stmtInfo[0] === "" ? "empty" : "set") . "|"; +echo $db->errorCode(); +"#, + ); + assert_eq!(out, "empty:n|null:empty|00000"); +} + +/// W1: after a successful operation, `errorCode()` reports the `"00000"` success +/// SQLSTATE rather than a native integer code. +#[test] +fn test_pdo_error_code_success() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +echo $db->errorCode(); +"#, + ); + assert_eq!(out, "00000"); +} + +/// W1: `errorInfo()` on success is the PHP-shaped triple `["00000", null, null]`. +#[test] +fn test_pdo_error_info_success() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$info = $db->errorInfo(); +echo $info[0] . "|" . ($info[1] === null ? "n" : "x") . "|" . ($info[2] === null ? "n" : "x"); +"#, + ); + assert_eq!(out, "00000|n|n"); +} + +/// W1: a constraint violation surfaces the real SQLSTATE `23000` (php-src's +/// SQLite mapping) through `errorInfo()[0]` in silent mode. +#[test] +fn test_pdo_error_info_on_constraint() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); +$db->exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$info = $db->errorInfo(); +echo $info[0]; +"#, + ); + assert_eq!(out, "23000"); +} + +/// W1: in the default EXCEPTION mode a failed statement throws a `PDOException` +/// whose `errorInfo[0]` carries the SQLSTATE frameworks parse. +#[test] +fn test_pdo_exception_carries_error_info() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +try { + $db->exec("INSERT INTO t (id) VALUES (1)"); + echo "no-throw"; +} catch (PDOException $e) { + echo $e->errorInfo[0]; +} +"#, + ); + assert_eq!(out, "23000"); +} + +/// W1: a statement tracks its own error state independently of the connection. +#[test] +fn test_pdo_statement_error_info() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); +$db->exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$stmt = $db->prepare("INSERT INTO t (id) VALUES (1)"); +$stmt->execute(); +$info = $stmt->errorInfo(); +echo $info[0]; +"#, + ); + assert_eq!(out, "23000"); +} + +/// W2: `inTransaction()` tracks the transaction lifecycle across begin/commit. +#[test] +fn test_pdo_in_transaction_flag() { + let out = compile_and_run( + r#"inTransaction() ? "1" : "0"; +$db->beginTransaction(); +echo $db->inTransaction() ? "1" : "0"; +$db->commit(); +echo $db->inTransaction() ? "1" : "0"; +"#, + ); + assert_eq!(out, "010"); +} + +/// W2: a committed transaction persists its writes. +#[test] +fn test_pdo_transaction_commit_persists() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->beginTransaction(); +$db->exec("INSERT INTO t (id) VALUES (42)"); +$db->commit(); +$row = $db->query("SELECT id FROM t")->fetch(); +echo $row["id"]; +"#, + ); + assert_eq!(out, "42"); +} + +/// W2: starting a nested transaction is a logic error and throws. +#[test] +fn test_pdo_double_begin_throws() { + let out = compile_and_run( + r#"beginTransaction(); +try { + $db->beginTransaction(); + echo "no-throw"; +} catch (PDOException $e) { + echo $e->getMessage(); +} +"#, + ); + assert_eq!(out, "There is already an active transaction"); +} + +/// W2: committing with no active transaction is a logic error and throws. +#[test] +fn test_pdo_commit_without_transaction_throws() { + let out = compile_and_run( + r#"commit(); + echo "no-throw"; +} catch (PDOException $e) { + echo $e->getMessage(); +} +"#, + ); + assert_eq!(out, "There is no active transaction"); +} + +/// P1-g: `inTransaction()` reads the driver's LIVE state, not just a PHP-side +/// flag — SQLite's `sqlite3_get_autocommit` reports a transaction started by a +/// raw `exec("BEGIN")` (bypassing `beginTransaction()`) as active, and reports +/// it to the ordinary `PDO::commit()` guard, which clears it successfully. +#[test] +fn test_pdo_in_transaction_reflects_raw_begin() { + let out = compile_and_run( + r#"exec("BEGIN"); +echo $db->inTransaction() ? "1" : "0"; +$db->commit(); +echo $db->inTransaction() ? "1" : "0"; +"#, + ); + assert_eq!(out, "10"); +} + +/// P1-g: `beginTransaction()`'s already-active guard also consults the live +/// state, so it raises PHP's clean "already an active transaction" error even +/// when the transaction was started by a raw `exec("BEGIN")` rather than by +/// `beginTransaction()` itself (which never ran, so `$inTxn` alone would have +/// missed it). +#[test] +fn test_pdo_begin_after_raw_begin_throws() { + let out = compile_and_run( + r#"exec("BEGIN"); +try { + $db->beginTransaction(); + echo "no-throw"; +} catch (PDOException $e) { + echo $e->getMessage(); +} +"#, + ); + assert_eq!(out, "There is already an active transaction"); +} + +/// W2: `PDO::getAvailableDrivers()` is a static returning the dispatchable drivers. +#[test] +fn test_pdo_get_available_drivers() { + let out = compile_and_run( + r#"quote("O'Brien"); +"#, + ); + assert_eq!(out, "'O''Brien'"); +} + +/// W3: `fetch()` on a statement that was never executed returns false instead of +/// silently stepping the query with NULL binds. +#[test] +fn test_pdo_fetch_before_execute_returns_false() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$stmt = $db->prepare("SELECT id FROM t"); +$r = $stmt->fetch(); +echo $r === false ? "false" : "row"; +"#, + ); + assert_eq!(out, "false"); +} + +/// W3: `closeCursor()` requires a re-execute before the next fetch succeeds. +#[test] +fn test_pdo_close_cursor() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (5)"); +$stmt = $db->query("SELECT id FROM t"); +$first = $stmt->fetch(); +$stmt->closeCursor(); +$after = $stmt->fetch(); +echo $first["id"] . "|" . ($after === false ? "false" : "row"); +"#, + ); + assert_eq!(out, "5|false"); +} + +/// W3: `fetchObject()` builds a stdClass with one property per column. +#[test] +fn test_pdo_fetch_object() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t (id, name) VALUES (5, 'Zoe')"); +$o = $db->query("SELECT id, name FROM t")->fetchObject(); +echo $o->id . ":" . $o->name; +"#, + ); + assert_eq!(out, "5:Zoe"); +} + +/// P2-7: a fresh SQLite connection seeds a 60s (60000ms) busy-timeout by default +/// (matching real PHP, verified against a PHP 8.5 CLI: `PRAGMA busy_timeout` +/// reports `60000` right after `new PDO(...)`), rather than the pre-fix `0` +/// (immediate `SQLITE_BUSY` on any lock contention). +#[test] +fn test_pdo_sqlite_default_busy_timeout_is_60000() { + let out = compile_and_run( + r#"query("PRAGMA busy_timeout")->fetchColumn(); +"#, + ); + assert_eq!(out, "60000"); +} + +/// `ATTR_TIMEOUT` set via `setAttribute()` changes SQLite's live busy-timeout. Like +/// php-src's SQLite driver, the write-only attribute is not readable via PDO. +#[test] +fn test_pdo_attr_timeout_set_attribute() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_TIMEOUT, 5); +echo ($ok ? "set" : "failed") . "|" . $db->query("PRAGMA busy_timeout")->fetchColumn(); +"#, + ); + assert_eq!(out, "set|5000"); +} + +/// `ATTR_TIMEOUT` passed as a constructor option is applied after the open. +#[test] +fn test_pdo_attr_timeout_constructor_option() { + let out = compile_and_run( + r#" 3]); +echo $db->query("PRAGMA busy_timeout")->fetchColumn(); +"#, + ); + assert_eq!(out, "3000"); +} + +/// P1-10: `Pdo\Sqlite::ATTR_OPEN_FLAGS` (a constructor option) threads through to +/// the bridge open — `OPEN_READONLY` opens a connection that rejects a write +/// (`exec()` returns false) against a file a prior read-write connection created. +#[test] +fn test_pdo_sqlite_attr_open_flags_readonly_rejects_write() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (n INTEGER)"); + +$ro = new \Pdo\Sqlite("sqlite:" . $path, null, null, [ + \Pdo\Sqlite::ATTR_OPEN_FLAGS => \Pdo\Sqlite::OPEN_READONLY, + PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, +]); +$result = $ro->exec("INSERT INTO t VALUES (1)"); +echo ($result === false) ? "rejected" : "allowed"; +unlink($path); +"#, + ); + assert_eq!(out, "rejected"); +} + +/// P2-9: a `sqlite:file:...?mode=ro` DSN body enables `SQLITE_OPEN_URI`, so +/// `mode=ro` is honored — opening a nonexistent database this way throws +/// (read-only cannot create the file) instead of silently creating a new file +/// at the literal, unparsed `file:...?mode=ro` path. Verified against a real +/// PHP 8.5 CLI with the same DSN shape. +#[test] +fn test_pdo_sqlite_file_uri_dsn_mode_ro_nonexistent_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"); +$db->exec("INSERT INTO t (name) VALUES ('a')"); +$db->exec("INSERT INTO t (name) VALUES ('b')"); +echo $db->lastInsertId(); +"#, + ); + assert_eq!(out, "2"); +} + +/// F-CORE-18: `lastInsertId()`'s success path still returns a plain string +/// (compares `===` equal, not a boxed/coerced value) now that its return type is +/// `string|bool` — SQLite always succeeds after an insert, so this is a +/// regression guard that widening the signature left the success arm untouched. +#[test] +fn test_pdo_last_insert_id_success_strict_string() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"); +$db->exec("INSERT INTO t (name) VALUES ('a')"); +echo ($db->lastInsertId() === "1") ? "ok" : "bad"; +"#, + ); + assert_eq!(out, "ok"); +} + +/// Verifies the default PHP 8.5 compatibility mode exposes compact fetch flags and the remaining +/// core constant values. +#[test] +fn test_pdo_constants_present() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (category TEXT, value TEXT)"); +$db->exec("INSERT INTO t VALUES ('a', 'x'), ('a', 'y')"); +$rows = $db->query("SELECT category, value FROM t ORDER BY rowid") + ->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_COLUMN); +echo PDO::FETCH_GROUP . "," . PDO::FETCH_UNIQUE . "," . PDO::FETCH_CLASSTYPE . "," + . PDO::FETCH_PROPS_LATE . "," . PDO::FETCH_SERIALIZE . "|" + . $rows["a"][0] . $rows["a"][1]; +"#, + PhpVersion::Php85, + ); + assert_eq!(out, "32,64,128,256,512|xy"); +} + +/// Verifies a pre-8.4 target keeps the legacy driver-extension methods usable +/// even though namespaced driver classes and `PDO::connect()` are not generated. +#[test] +fn test_pdo_php83_legacy_sqlite_surface_executes() { + let out = compile_and_run_with_php_version( + r#"sqliteCreateFunction("double_it", function($value) { return $value * 2; }, 1); +echo $db->query("SELECT double_it(6)")->fetchColumn(); +"#, + PhpVersion::Php83, + ); + assert_eq!(out, "12"); +} + +/// Verifies PHP 8.5 exposes and executes the new SQLite connection and statement attributes. +#[test] +fn test_pdo_php85_sqlite_transaction_busy_and_explain_attributes() { + let out = compile_and_run_with_php_version( + r#"getAttribute(Pdo\Sqlite::ATTR_TRANSACTION_MODE); +echo $db->setAttribute(Pdo\Sqlite::ATTR_TRANSACTION_MODE, Pdo\Sqlite::TRANSACTION_MODE_IMMEDIATE) ? "T" : "F"; +echo $db->getAttribute(Pdo\Sqlite::ATTR_TRANSACTION_MODE) . "|"; +$stmt = $db->prepare("SELECT 1 AS value"); +echo $stmt->getAttribute(Pdo\Sqlite::ATTR_BUSY_STATEMENT) ? "T" : "F"; +echo $stmt->setAttribute(Pdo\Sqlite::ATTR_EXPLAIN_STATEMENT, Pdo\Sqlite::EXPLAIN_MODE_EXPLAIN) ? "T" : "F"; +echo $stmt->getAttribute(Pdo\Sqlite::ATTR_EXPLAIN_STATEMENT); +$stmt->execute(); +echo $stmt->getAttribute(Pdo\Sqlite::ATTR_BUSY_STATEMENT) ? "T" : "F"; +"#, + PhpVersion::Php85, + ); + assert_eq!(out, "1003,1004,1005,0,1,2|0T1|FT1T"); +} + +/// PHP 8.5 SQLite driver attributes supplied in the constructor must affect the +/// newly opened native handle just like later `setAttribute()` calls; their +/// numeric collision with MySQL options must not divert them into MySQL config. +#[test] +fn test_pdo_php85_sqlite_constructor_attributes_reach_native_handle() { + let out = compile_and_run_with_php_version( + r#" Pdo\Sqlite::TRANSACTION_MODE_IMMEDIATE, + Pdo\Sqlite::ATTR_EXTENDED_RESULT_CODES => true, +]); +$db->exec("CREATE TABLE t (id INTEGER UNIQUE)"); +$db->exec("INSERT INTO t VALUES (1)"); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); +$db->exec("INSERT INTO t VALUES (1)"); +echo $db->getAttribute(Pdo\Sqlite::ATTR_TRANSACTION_MODE) . ":" . $db->errorInfo()[1]; +"#, + PhpVersion::Php85, + ); + assert_eq!(out, "1:2067"); +} + +/// PHP 8.6 adopts pdo_pgsql's persistent-disconnect `DISCARD ALL`: the final +/// live PDO owner resets session state, while releasing one of two simultaneous +/// owners must not disrupt the still-live object sharing that pooled handle. +#[test] +#[ignore] +fn test_pdo_php86_pgsql_persistent_release_discards_session_state() { + let out = compile_and_run_with_php_version( + r#" "php86-reset"]); +$b = new PDO($dsn, null, null, [PDO::ATTR_PERSISTENT => "php86-reset"]); +$pid = $a->query("SELECT pg_backend_pid()::text")->fetchColumn(); +$a->exec("SET application_name = 'elephc-dirty'"); +$a = null; +$stillDirty = $b->query("SHOW application_name")->fetchColumn(); +$b = null; +$c = new PDO($dsn, null, null, [PDO::ATTR_PERSISTENT => "php86-reset"]); +$samePid = $c->query("SELECT pg_backend_pid()::text")->fetchColumn(); +$reset = $c->query("SHOW application_name")->fetchColumn(); +echo $stillDirty . ":" . (($pid === $samePid) ? "same" : "new") . ":[" . $reset . "]"; +"#, + PhpVersion::Php86, + ); + assert_eq!(out, "elephc-dirty:same:[]"); +} + +/// Releasing a dynamically allocated PDOStatement drops its rooted PDO owner, so +/// overwriting the connection's last userland reference runs its destructor. +#[test] +fn test_pdo_statement_release_drops_connection_owner() { + let out = compile_and_run( + r#"query("SELECT 1"); +unset($stmt); +$db = null; +echo TrackedPDO::$alive; +"#, + ); + assert_eq!(out, "0"); +} + +/// Verifies PHP 8.5's SQLite authorizer receives the five php-src arguments, +/// controls statement preparation, and can be removed with a nullable reset. +#[test] +fn test_pdo_php85_sqlite_authorizer_callback_and_reset() { + let out = compile_and_run_with_php_version( + r#"setAuthorizer(function($action, $arg1, $arg2, $arg3, $arg4) { + echo $action . ":" . $arg1 . ":" . $arg2 . ":" . $arg3 . ":" . $arg4 . ";"; + return Pdo\Sqlite::OK; +}); +echo $db->query("SELECT 7")->fetchColumn() . "|"; +$db->setAuthorizer(function($action, $arg1, $arg2, $arg3, $arg4) { + return Pdo\Sqlite::DENY; +}); +try { + $db->exec("CREATE TABLE denied (value INTEGER)"); + echo "allowed|"; +} catch (PDOException $error) { + echo $error->errorInfo[1] . "|"; +} +$db->setAuthorizer(function() { return "FAIL"; }); +try { + $db->query("SELECT 1"); +} catch (Error $error) { + $_message = $error->getMessage(); + echo $_message; + echo "|"; +} +$db->setAuthorizer(function() { return 4200; }); +try { + $db->query("SELECT 1"); +} catch (Error $error) { + $_message = $error->getMessage(); + echo $_message; + echo "|"; +} +$db->setAuthorizer(null); +echo $db->exec("CREATE TABLE t (value INTEGER)"); +"#, + PhpVersion::Php85, + ); + assert_eq!( + out, + "21::::;7|23|PDO::query(): Return value of the authorizer callback must be of type int, string returned|PDO::query(): Return value of the authorizer callback must be one of Pdo\\Sqlite::OK, Pdo\\Sqlite::DENY, or Pdo\\Sqlite::IGNORE|0" + ); +} + +/// Verifies SQLite callback registration normalizes every PHP callable form +/// through a rooted closure descriptor for scalar, collation, and aggregate hooks. +#[test] +fn test_pdo_sqlite_callbacks_accept_all_callable_forms() { + let out = compile_and_run( + r#"createFunction("named_twice", "pdo_named_twice", 1); +$db->createFunction("static_triple", [PdoSqliteCallbackForms::class, "triple"], 1); +$db->createFunction("instance_four", [$handlers, "quadruple"], 1); +$db->createFunction("invoke_five", new PdoSqliteInvoker(), 1); +$db->createCollation("reverse_named", "pdo_reverse_compare"); +$db->createAggregate("named_sum", "pdo_sum_step", "pdo_sum_final", 1); +$row = $db->query("SELECT named_twice(2), static_triple(2), instance_four(2), invoke_five(2)")->fetch(PDO::FETCH_NUM); +echo $row[0] . $row[1] . $row[2] . $row[3] . ":"; +$values = $db->query("SELECT 'a' AS value UNION ALL SELECT 'b' ORDER BY value COLLATE reverse_named")->fetchAll(PDO::FETCH_COLUMN); +echo $values[0] . $values[1] . ":"; +echo $db->query("SELECT named_sum(value) FROM (SELECT 2 AS value UNION ALL SELECT 3)")->fetchColumn(); +"#, + ); + assert_eq!(out, "46810:ba:5"); +} + +/// Verifies replacement roots use SQLite's case-insensitive `(name, arity)` key: +/// replacing one scalar arity releases only that descriptor while another arity +/// remains callable, including receiver-bound descriptors created from arrays. +#[test] +fn test_pdo_sqlite_callback_replacement_preserves_other_arities() { + let out = compile_and_run( + r#"createFunction("Calc", [$handler, "twice"], 1); +$db->createFunction("calc", function($left, $right) { return $left + $right; }, 2); +echo $db->query("SELECT calc(3), CALC(3, 4)")->fetchColumn(0) . ":"; +$db->createFunction("CALC", function($value) { return $value * 3; }, 1); +$row = $db->query("SELECT calc(3), calc(3, 4)")->fetch(PDO::FETCH_NUM); +echo $row[0] . ":" . $row[1]; +"#, + ); + assert_eq!(out, "6:9:7"); +} + +/// Verifies PDO teardown unregisters native callbacks before a persistent SQLite +/// handle is reused, so the next object cannot call a descriptor whose PHP root +/// belonged to the previous object. The destructor is invoked explicitly to make +/// the teardown point deterministic while a second handle is opened in one fixture. +#[test] +fn test_pdo_persistent_sqlite_callbacks_are_cleared_before_pool_reuse() { + let out = compile_and_run( + r#" true]); + $db->sqliteCreateFunction("temporary_callback", function() { return 41; }, 0); + $stmt = $db->query("SELECT temporary_callback() + 1"); + echo $stmt->fetchColumn() . ":"; + unset($stmt); + $db->__destruct(); + unset($db); +} + +install_persistent_callback(); +$reused = new PDO("sqlite::memory:", null, null, [PDO::ATTR_PERSISTENT => true]); +try { + $reused->query("SELECT temporary_callback()"); + echo "dangling"; +} catch (PDOException $error) { + echo "cleared"; +} +"#, + ); + assert_eq!(out, "42:cleared"); +} + +/// Verifies PHP 8.5's tightened fetch validation rejects class-only flags on +/// other modes and rejects FETCH_INTO from fetchAll(), while PHP 8.4 keeps its +/// historical acceptance in the existing default-version regressions. +#[test] +fn test_pdo_php85_fetch_flag_and_fetch_into_validation() { + let out = compile_and_run_with_php_version( + r#"query("SELECT 1 AS value"); +try { + $stmt->setFetchMode(PDO::FETCH_ASSOC | PDO::FETCH_PROPS_LATE); +} catch (ValueError $error) { + echo "set|"; +} +try { + $stmt->fetchAll(PDO::FETCH_INTO, new stdClass()); +} catch (ValueError $error) { + echo "all|"; +} +try { + $stmt->fetch(PDO::FETCH_NUM | PDO::FETCH_SERIALIZE); +} catch (ValueError $error) { + echo "fetch"; +} +"#, + PhpVersion::Php85, + ); + assert_eq!(out, "set|all|fetch"); +} + +/// The `Pdo\Mysql::ATTR_SSL_*` constants that drive MySQL TLS carry their PHP-8.4 +/// (mysqlnd) values, and referencing them compiles. +#[test] +fn test_pdo_mysql_ssl_constants_present() { + let out = compile_and_run( + r#" "/nonexistent/ca.pem", Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT => false]); +echo $db->query("SELECT 42")->fetchColumn(); +"#, + ); + assert_eq!(out, "42"); +} + +/// W4: `ATTR_DEFAULT_FETCH_MODE` set via setAttribute() governs a no-mode fetch(). +#[test] +fn test_pdo_default_fetch_mode_set_attribute() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +$db->exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t (id, name) VALUES (1, 'Ada')"); +$row = $db->query("SELECT id, name FROM t")->fetch(); +echo $row["name"] . "|" . (isset($row[0]) ? "both" : "assoc"); +"#, + ); + assert_eq!(out, "Ada|assoc"); +} + +/// W4: `ATTR_DEFAULT_FETCH_MODE` passed as a constructor option is honored. +#[test] +fn test_pdo_default_fetch_mode_constructor() { + let out = compile_and_run( + r#" PDO::FETCH_NUM]); +$db->exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (9)"); +$row = $db->query("SELECT id FROM t")->fetch(); +echo $row[0]; +"#, + ); + assert_eq!(out, "9"); +} + +/// W4: `getAttribute(ATTR_DEFAULT_FETCH_MODE)` reads back the stored default. +#[test] +fn test_pdo_get_default_fetch_mode() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_OBJ); +echo $db->getAttribute(PDO::ATTR_DEFAULT_FETCH_MODE); +"#, + ); + assert_eq!(out, "5"); +} + +/// P3: `setAttribute(ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_CLASS)` (and +/// `FETCH_INTO`) is ACCEPTED — mirroring php-src's `pdo_dbh.c` exactly +/// (verified against php-src): the "PDO::FETCH_INTO and PDO::FETCH_CLASS +/// cannot be set as the default fetch mode" rejection only fires when the +/// given value is an ARRAY whose element `[0]` is one of those modes (the +/// `setAttribute(ATTR_DEFAULT_FETCH_MODE, [PDO::FETCH_CLASS, 'Foo'])` idiom); +/// a BARE int is accepted and stored like any other mode. elephc's +/// `setAttribute()` only ever narrows its `mixed $value` with `(int) $value`, +/// so the array form never reaches this check and has no elephc analogue — +/// only `PDO::FETCH_USE_DEFAULT` (i.e. `PDO::FETCH_DEFAULT`, 0) is still +/// rejected. Supersedes the old `test_pdo_default_fetch_mode_rejects_class`, +/// which asserted the opposite (a real php-src divergence this slice fixes). +#[test] +fn test_pdo_default_fetch_mode_accepts_bare_class_and_into() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_CLASS); +echo $db->getAttribute(PDO::ATTR_DEFAULT_FETCH_MODE); +$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_INTO); +echo ":" . $db->getAttribute(PDO::ATTR_DEFAULT_FETCH_MODE); +try { + $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_DEFAULT); + echo ":no-throw"; +} catch (\ValueError $e) { + echo ":threw-default"; +} +"#, + ); + assert_eq!(out, "8:9:threw-default"); +} + +/// P3 regression: after accepting a bare `FETCH_CLASS` default (see above), +/// `PDO::prepare()` must still succeed — the connection-wide default is +/// propagated to the new statement via a raw, unvalidated field copy +/// (mirroring php-src's `stmt->default_fetch_type = dbh->default_fetch_type`) +/// rather than through `setFetchMode()`'s own argument-count validation, +/// which would otherwise wrongly reject this now-legal stored default the +/// moment ANY statement is prepared on the connection. With no class ever +/// registered, `fetch()` falls back to `stdClass` (its own pre-existing, +/// documented behavior for a target-less `FETCH_CLASS`). +#[test] +fn test_pdo_default_fetch_mode_bare_class_survives_prepare() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_CLASS); +$db->exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada')"); +$stmt = $db->prepare("SELECT id, name FROM t"); +$stmt->execute(); +$row = $stmt->fetch(); +echo (($row instanceof stdClass) ? "stdClass" : "other") . ":" . $row->id . ":" . $row->name; +"#, + ); + assert_eq!(out, "stdClass:1:Ada"); +} + +/// W4: `fetchAll(FETCH_KEY_PAIR)` maps a 2-column result to `[col0 => col1]`. +#[test] +fn test_pdo_fetch_all_key_pair() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada')"); +$db->exec("INSERT INTO t VALUES (2, 'Bob')"); +$pairs = $db->query("SELECT id, name FROM t ORDER BY id")->fetchAll(PDO::FETCH_KEY_PAIR); +$out = count($pairs) . "|"; +foreach ($pairs as $k => $v) { $out .= $k . ":" . $v . ";"; } +echo $out; +"#, + ); + assert_eq!(out, "2|1:Ada;2:Bob;"); +} + +/// W4: `FETCH_KEY_PAIR` on a result without exactly two columns throws. +#[test] +fn test_pdo_fetch_key_pair_wrong_column_count_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a INTEGER, b INTEGER, c INTEGER)"); +$db->exec("INSERT INTO t VALUES (1, 2, 3)"); +try { + $db->query("SELECT a, b, c FROM t")->fetchAll(PDO::FETCH_KEY_PAIR); + echo "no-throw"; +} catch (PDOException $e) { + echo "threw"; +} +"#, + ); + assert_eq!(out, "threw"); +} + +/// P3: the `FETCH_KEY_PAIR` wrong-column-count message matches php-src's exact +/// wording (verified against php-src's `pdo_stmt.c`: emitted via +/// `pdo_raise_impl_error(stmt->dbh, stmt, "HY000", ...)`), including the +/// trailing period — not elephc's previous, shorter invented text. +#[test] +fn test_pdo_fetch_key_pair_wrong_column_count_message_matches_php_src() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a INTEGER, b INTEGER, c INTEGER)"); +$db->exec("INSERT INTO t VALUES (1, 2, 3)"); +try { + $db->query("SELECT a, b, c FROM t")->fetchAll(PDO::FETCH_KEY_PAIR); + echo "no-throw"; +} catch (PDOException $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:SQLSTATE[HY000]: General error: PDO::FETCH_KEY_PAIR fetch mode requires the result set to contain exactly 2 columns." + ); +} + +/// P2-b: the `FETCH_KEY_PAIR` wrong-column-count error is errMode-aware, mirroring +/// php-src's `pdo_raise_impl_error` (SQLSTATE "HY000") rather than an +/// unconditional throw — under `ERRMODE_SILENT` a 3-column `fetch(FETCH_KEY_PAIR)` +/// returns `false` instead of throwing. +#[test] +fn test_pdo_fetch_key_pair_wrong_column_count_respects_silent() { + let out = compile_and_run( + r#" PDO::ERRMODE_SILENT]); +$db->exec("CREATE TABLE t (a INTEGER, b INTEGER, c INTEGER)"); +$db->exec("INSERT INTO t VALUES (1, 2, 3)"); +$stmt = $db->query("SELECT a, b, c FROM t"); +$row = $stmt->fetch(PDO::FETCH_KEY_PAIR); +echo ($row === false) ? "false" : "other"; +"#, + ); + assert_eq!(out, "false"); +} + +/// F-STMT-03: `fetchAll(PDO::FETCH_LAZY)` is the one place real PHP forbids +/// FETCH_LAZY, and PHP 8.5 reports the expanded method-specific ValueError. +/// `pdo_stmt_verify_mode` takes a `fetch_all` flag and refuses FETCH_LAZY on that arm +/// ALONE — because a lazy PDORow is a view onto the CURRENT row, so a list of them +/// would all alias the last one. +/// +/// This prelude used to have the restriction exactly BACKWARDS (it rejected LAZY in +/// `fetch()`, where php-src allows it, and accepted it here, where php-src does not), +/// and the old `test_pdo_fetch_lazy_unsupported_throws` locked the inversion in. +#[test] +fn test_pdo_fetch_all_lazy_throws_value_error() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +try { + $db->query("SELECT id FROM t")->fetchAll(PDO::FETCH_LAZY); + echo "no-throw"; +} catch (ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:PDOStatement::fetchAll(): Argument #1 ($mode) PDO::FETCH_LAZY cannot be used with PDOStatement::fetchAll()" + ); +} + +/// Verifies PHP 8.4 and older retain the shorter FETCH_LAZY ValueError wording. +#[test] +fn test_pdo_php84_fetch_all_lazy_uses_legacy_value_error() { + let out = compile_and_run_with_php_version( + r#"query("SELECT 1")->fetchAll(PDO::FETCH_LAZY); + echo "no-throw"; +} catch (ValueError $e) { + echo $e->getMessage(); +} +"#, + PhpVersion::Php84, + ); + assert_eq!( + out, + "PDOStatement::fetchAll(): Argument #1 ($mode) cannot be PDO::FETCH_LAZY" + ); +} + +/// W7 namespace prerequisite (smoke test, no prelude change): a user-defined +/// class in a block namespace can `extends \PDO`, inherit the real prelude +/// `PDO::__construct` (a genuine emitted body, not a synthesized stub), dispatch +/// inherited methods (`exec`/`query`/`fetch`) through the vtable, and satisfy +/// `instanceof \PDO`. This proves the single unproven capability the shipped +/// `Pdo\Sqlite`/`Mysql`/`Pgsql` subclasses depend on: a namespaced class whose +/// method symbols mangle `\` to `_N_` links and inherits from the flat prelude +/// class it extends. +#[test] +fn test_pdo_namespaced_subclass_extends_prelude_pdo() { + let out = compile_and_run( + r#"exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"); + $db->exec("INSERT INTO users (name) VALUES ('Ada')"); + $row = $db->query("SELECT id, name FROM users")->fetch(\PDO::FETCH_ASSOC); + $is_pdo = $db instanceof \PDO ? "1" : "0"; + echo $row["id"] . ":" . $row["name"] . ":" . $is_pdo; +} +"#, + ); + assert_eq!(out, "1:Ada:1"); +} + +/// W7 prelude subclasses: a program that references only `Pdo\Sqlite` — never the +/// base `PDO` name — still injects the prelude (driver-subclass detection) and the +/// subclass drives an in-memory database through its inherited base methods. +#[test] +fn test_pdo_driver_subclass_sqlite_alone_triggers_injection() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"); +$db->exec("INSERT INTO t (name) VALUES ('Zed')"); +echo $db->query("SELECT name FROM t")->fetchColumn(); +"#, + ); + assert_eq!(out, "Zed"); +} + +/// W7 prelude subclasses: a `Pdo\Sqlite` instance satisfies `instanceof` for both +/// its own namespaced class and the base `\PDO` it extends, confirming the +/// inheritance edge survives injection, name resolution, and `\`->`_N_` mangling. +#[test] +fn test_pdo_driver_subclass_instanceof_edges() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"); +$db->exec("INSERT INTO t (name) VALUES ('Ada')"); +$name = $db->query("SELECT name FROM t")->fetchColumn(); +$is_sqlite = $db instanceof \Pdo\Sqlite ? "1" : "0"; +$is_pdo = $db instanceof \PDO ? "1" : "0"; +echo $name . ":" . $is_sqlite . $is_pdo; +"#, + ); + assert_eq!(out, "Ada:11"); +} + +/// `PDO::connect()` with a DSN whose prefix matches no known driver throws a +/// `PDOException` ("could not find driver"), matching PHP's factory behavior. +#[test] +fn test_pdo_connect_unknown_driver_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER)"); + $db->exec("INSERT INTO t (n) VALUES (7)"); + return (int) $db->query("SELECT n FROM t")->fetchColumn(); +} +echo seed(\PDO::connect("sqlite::memory:")); +"#, + ); + assert_eq!(out, "7"); +} + +/// `PDO::connect()` preserves late-static driver subclasses and rejects a +/// driver mismatch before attempting a connection, with php-src's exact guidance. +#[test] +fn test_pdo_connect_late_static_driver_compatibility() { + let out = compile_and_run( + r#"getMessage(); +} +"#, + ); + assert_eq!( + out, + "AppSqlite|AppSqlite::connect() cannot be used for connecting to the \"mysql\" driver, either call Pdo\\Mysql::connect() or PDO::connect() instead" + ); +} + +/// A generic PDO subclass is constructor-compatible for legacy code but cannot +/// select a driver-specific class through the PHP 8.4 static factory. +#[test] +fn test_pdo_connect_rejects_generic_pdo_subclass_and_unknown_driver_scope() { + let out = compile_and_run( + r#"getMessage(), "|"; +} +try { + \Pdo\Sqlite::connect("unknown:anything"); +} catch (PDOException $e) { + echo $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "AppPdo::connect() cannot be used for connecting to the \"sqlite\" driver, either call Pdo\\Sqlite::connect() or PDO::connect() instead|Pdo\\Sqlite::connect() cannot be used for connecting to an unknown driver, call PDO::connect() instead" + ); +} + +/// Driver subclasses declare their own PHP 8.4 constants (not just inherited base +/// PDO ones): SQLite DETERMINISTIC / ATTR_OPEN_FLAGS, MySQL ATTR_LOCAL_INFILE, +/// PostgreSQL ATTR_DISABLE_PREPARES / TRANSACTION_INERROR. Static constant access +/// errors on an undefined member at compile time, so a passing result proves each +/// namespaced subclass carries its own declared constants. +#[test] +fn test_pdo_driver_subclass_own_constants() { + let out = compile_and_run( + r#" PDO::ERRMODE_SILENT]); +$db->exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"); +$db->exec("INSERT INTO t (name) VALUES ('Ada')"); +$stmt = $db->prepare("SELECT name FROM t WHERE id = 1"); +$qs = $stmt->queryString; +$set = $stmt->setAttribute(19, 5) ? "T" : "F"; +$missing = $stmt->getAttribute(999) === false ? "false" : "?"; +$stmt->execute(); +$more = $stmt->nextRowset() ? "1" : "0"; +echo $qs . "|" . $set . "|" . $missing . "|" . $more; +"#, + ); + assert_eq!(out, "SELECT name FROM t WHERE id = 1|F|false|0"); +} + +/// P2-c/P3: `nextRowset()` raises IM001 ("driver does not support multiple +/// rowsets", php-src's exact wording) for a SQLite statement instead of +/// silently returning `false`, mirroring php-src's `pdo_raise_impl_error` for +/// a driver with no further-rowset primitive — errMode-aware like every other +/// statement failure: SILENT swallows it and still returns `false` (checked on +/// a separate connection above in +/// test_pdo_statement_querystring_attributes_nextrowset), EXCEPTION throws a +/// `PDOException` carrying the SQLSTATE. +#[test] +fn test_pdo_statement_nextrowset_raises_im001() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t VALUES (1)"); +$stmt = $db->query("SELECT id FROM t"); +$threw = "no"; +try { + $stmt->nextRowset(); +} catch (PDOException $e) { + $threw = $e->errorInfo[0]; +} +echo $threw; +"#, + ); + assert_eq!(out, "IM001"); +} + +/// P3: the `nextRowset()` IM001 message matches php-src's exact wording +/// (verified against php-src's `pdo_stmt.c`) — "driver does not support +/// multiple rowsets", not elephc's previous invented "This driver doesn't +/// support multiple rowsets" prefix. +#[test] +fn test_pdo_statement_nextrowset_message_matches_php_src() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t VALUES (1)"); +$stmt = $db->query("SELECT id FROM t"); +try { + $stmt->nextRowset(); + echo "no-throw"; +} catch (PDOException $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:SQLSTATE[IM001]: Driver does not support this function: driver does not support multiple rowsets" + ); +} + +/// P1-j: a `PDOStatement` keeps its owning `PDO` (and therefore its bridge +/// connection) alive for as long as the statement itself is reachable, even +/// after the local variable holding the `PDO` goes out of scope. `PDO::query()` +/// (via `prepare()`) passes `$this` into the new statement's `setOwner()`, +/// stored in a private `?PDO $owner` property — a plain object-typed property +/// reference is enough for elephc's refcounting GC to keep the referenced +/// object (and, transitively, its bridge connection) alive. No reference cycle +/// is created: `PDO` does not hold a reference back to any of its statements. +/// +/// `q()` is deliberately left without a declared return type: `PDO::query()`'s +/// real signature is `PDOStatement|bool`, and elephc's checker does not +/// flow-narrow that union down to `PDOStatement` even behind an `=== false` +/// guard, so a `: PDOStatement` return type on `q()` fails to type-check here +/// — an unrelated checker limitation, not part of what this test verifies. +#[test] +fn test_pdo_statement_keeps_connection_alive() { + let out = compile_and_run( + r#"exec("CREATE TABLE t(n)"); + $db->exec("INSERT INTO t VALUES(42)"); + return $db->query("SELECT n FROM t"); +} +echo q()->fetchColumn(); +"#, + ); + assert_eq!(out, "42"); +} + +/// P2-o: constructing a `PDOStatement` directly (not via `PDO::prepare()` / +/// `query()`) throws a `PDOException` — mirroring php-src's "You should not +/// create a PDOStatement manually" — because the given `$connection` is not a +/// real, currently-open connection handle (`elephc_pdo_driver_name()` returns +/// `""` for an unknown id). This is the only way to reach the constructor +/// directly, since elephc never exposes a valid handle to PHP code. +#[test] +fn test_pdo_statement_direct_construction_throws() { + let out = compile_and_run( + r#"getMessage(); +} +"#, + ); + assert_eq!(out, "threw:You should not create a PDOStatement manually"); +} + +/// P1-i: `PDOStatement::setAttribute()`/`getAttribute()` on an unsupported +/// attribute raise IM001 under `ERRMODE_EXCEPTION` (the connection default) — +/// mirroring php-src's `pdo_raise_impl_error(stmt->dbh, stmt, "IM001", ...)`, +/// since no driver here registers a statement attribute hook. +#[test] +fn test_pdo_statement_set_attribute_unsupported_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$stmt = $db->prepare("SELECT id FROM t"); +try { + $stmt->setAttribute(12345, "x"); + echo "no-throw-set"; +} catch (PDOException $e) { + echo "threw-set:" . $e->errorInfo[0]; +} +try { + $stmt->getAttribute(12345); + echo ":no-throw-get"; +} catch (PDOException $e) { + echo ":threw-get:" . $e->errorInfo[0]; +} +"#, + ); + assert_eq!(out, "threw-set:IM001:threw-get:IM001"); +} + +/// PDOStatement::getColumnMeta (P1-8): native_type is always the runtime +/// storage-class name (never the raw declared DDL text), the declared type +/// lives under the separate "sqlite:decl_type" key, and an out-of-range column +/// index returns false. Verified against a real PHP 8.5 CLI with the same +/// schema and fixture row. +#[test] +fn test_pdo_statement_get_column_meta() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"); +$db->exec("INSERT INTO t (id, name) VALUES (7, 'Zed')"); +$stmt = $db->query("SELECT id, name FROM t"); +$stmt->fetch(); +$meta0 = $stmt->getColumnMeta(0); +$meta1 = $stmt->getColumnMeta(1); +$bad = $stmt->getColumnMeta(9) === false ? "F" : "?"; +echo $meta0["name"] . ":" . $meta0["native_type"] . ":" . $meta0["sqlite:decl_type"] . "," + . $meta0["table"] . ":" . $meta0["len"] . ":" . $meta0["precision"] . "," + . $meta1["name"] . ":" . $meta1["native_type"] . ":" . $meta1["sqlite:decl_type"] . "," . $bad; +"#, + ); + assert_eq!(out, "id:integer:INTEGER,t:-1:0,name:string:TEXT,F"); +} + +/// v43 REGRESSION GUARD: driver-specific MySQL and PostgreSQL metadata must not leak +/// into SQLite, while SQLite's common PDO descriptor fields and native source table +/// retain their php-src values. +/// +/// The exact KEY COUNT is the load-bearing assertion: it is what proves no `pgsql:*` key +/// leaked into a SQLite column's metadata. A SQLite column carries exactly 8 keys (name, +/// native_type, pdo_type, len, precision, flags, table, sqlite:decl_type); the pg branch +/// would add `pgsql:oid` and `pgsql:table_oid` and drop `sqlite:decl_type`. +/// +/// `len` and `precision` come from PDO's common column descriptor (`-1`/`0` here), while +/// `table` comes from SQLite's optional native column metadata API. +#[test] +fn test_pdo_get_column_meta_sqlite_shape_unchanged_by_the_mysql_and_pg_wiring() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, blob_col BLOB)"); +$db->exec("INSERT INTO t (id, name, blob_col) VALUES (7, 'Zed', X'00FF')"); +$stmt = $db->query("SELECT id, name, blob_col FROM t"); +$stmt->fetch(); + +$m = $stmt->getColumnMeta(0); +// 8 keys exactly: no pgsql:oid / pgsql:table_oid leaked in from the pg branch. +// Counted with foreach, NOT count($m): getColumnMeta() is declared `array|bool` (php-src's +// `array|false`), and count() on that union is a compile-time error here ("count() argument +// must be array or Countable object") — the checker will not narrow it. Indexing the union +// is fine, which is why every other read below is a plain subscript. +$keys = 0; +foreach ($m as $k => $v) { + $keys = $keys + 1; +} +echo $keys . ":" . $m["native_type"] . ":" . $m["pdo_type"] + . ":" . $m["len"] . ":" . $m["precision"] . ":[" . $m["table"] . "]" + . ":" . count($m["flags"]); + +// A BLOB still reports native_type "string" with "blob" pushed into flags (never its own +// native_type/pdo_type) — pdo_sqlite's storage-class rule, untouched by the MySQL override. +$b = $stmt->getColumnMeta(2); +echo "|" . $b["native_type"] . ":" . $b["pdo_type"] . ":" . implode(",", $b["flags"]); +"#, + ); + assert_eq!(out, "8:integer:1:-1:0:[t]:0|string:2:blob"); +} + +/// P2-h: `getColumnMeta()` on a prepared (not yet executed) statement returns +/// `false` — there is no result set at all to describe yet, distinct from the +/// existing out-of-range-column `false` case. +#[test] +fn test_pdo_get_column_meta_before_execute_returns_false() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$stmt = $db->prepare("SELECT id, name FROM t"); +$before = $stmt->getColumnMeta(0) === false ? "F" : "?"; +$stmt->execute(); +$after = $stmt->getColumnMeta(0) === false ? "F" : "array"; +echo $before . ":" . $after; +"#, + ); + assert_eq!(out, "F:array"); +} + +/// P3: `getColumnMeta($column)` with a negative `$column` throws a `ValueError` +/// — mirroring php-src's exact message and ordering (verified against +/// php-src's `PHP_METHOD(PDOStatement, getColumnMeta)`: the negative check is +/// pure argument validation that runs BEFORE any executed-state or +/// driver-dispatch check) — distinct from the `false` returned for a merely +/// out-of-range-HIGH column index (`test_pdo_statement_get_column_meta` +/// above) or a not-yet-executed statement +/// (`test_pdo_get_column_meta_before_execute_returns_false` above). Exercised +/// on a statement that hasn't been executed yet, so a wrong check order (the +/// `!executed` guard running first) would return `false` instead of throwing. +#[test] +fn test_pdo_get_column_meta_negative_column_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$stmt = $db->prepare("SELECT id FROM t"); +try { + $stmt->getColumnMeta(-1); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:PDOStatement::getColumnMeta(): Argument #1 ($column) must be greater than or equal to 0" + ); +} + +/// P1-4: `getColumnMeta()` called BEFORE the first explicit `fetch()` still +/// reports the real column types of the first row, not "no row yet" — elephc's +/// execute() eagerly pre-steps a SELECT-style statement, mirroring php-src's +/// pdo_sqlite `pre_fetched` behavior, and the subsequent explicit fetch() still +/// sees that same first row (verified against a real PHP 8.5 CLI with the same +/// schema and fixture rows). +#[test] +fn test_pdo_statement_get_column_meta_before_first_fetch() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, name TEXT, val REAL)"); +$db->exec("INSERT INTO t VALUES (1, 'a', 1.5)"); +$db->exec("INSERT INTO t VALUES (2, 'b', 2.5)"); +$stmt = $db->query("SELECT id, name, val FROM t ORDER BY id"); +$m0 = $stmt->getColumnMeta(0); +$m1 = $stmt->getColumnMeta(1); +$m2 = $stmt->getColumnMeta(2); +$before = $m0["native_type"] . "," . $m1["native_type"] . "," . $m2["native_type"]; +// The row that getColumnMeta() saw pre-fetched above must still be the first +// row an explicit fetch() returns — nothing was skipped. +$row = $stmt->fetch(PDO::FETCH_ASSOC); +echo $before . "|" . $row["id"] . ":" . $row["name"] . ":" . $row["val"]; +"#, + ); + assert_eq!(out, "integer,string,double|1:a:1.5"); +} + +/// P1-4: `getColumnMeta()` before the first fetch against an EMPTY result set +/// still reports "null" (there is no row, pre-fetched or otherwise, to derive a +/// real type from) — matching a real PHP 8.5 CLI. +#[test] +fn test_pdo_statement_get_column_meta_before_first_fetch_empty_result() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$stmt = $db->query("SELECT id FROM t"); +$m0 = $stmt->getColumnMeta(0); +echo $m0["native_type"] . ":" . ($stmt->fetch() === false ? "false" : "other"); +"#, + ); + assert_eq!(out, "null:false"); +} + +/// PDOStatement::getColumnMeta (P1-8): a BLOB column reports native_type +/// "string" (not "blob"), pushes "blob" into flags, and reports pdo_type +/// PARAM_STR (2, not PARAM_LOB) — matching pdo_sqlite's sqlite_statement.c +/// exactly (verified against a real PHP 8.5 CLI). +#[test] +fn test_pdo_statement_get_column_meta_blob_triple() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (data BLOB)"); +$db->exec("INSERT INTO t (data) VALUES (x'414243')"); +$stmt = $db->query("SELECT data FROM t"); +$stmt->fetch(); +$meta = $stmt->getColumnMeta(0); +echo $meta["native_type"] . ":" . $meta["pdo_type"] . ":" . $meta["flags"][0] . ":" . $meta["sqlite:decl_type"]; +"#, + ); + assert_eq!(out, "string:2:blob:BLOB"); +} + +/// PDOStatement::getColumnMeta (P1-8): an expression column with no declared +/// type omits the "sqlite:decl_type" key entirely, matching PHP (verified +/// against a real PHP 8.5 CLI). +#[test] +fn test_pdo_statement_get_column_meta_expression_no_decltype() { + let out = compile_and_run( + r#"query("SELECT 1 + 1 AS expr"); +$stmt->fetch(); +$meta = $stmt->getColumnMeta(0); +echo $meta["native_type"] . ":" . (isset($meta["sqlite:decl_type"]) ? "Y" : "N") + . ":" . (isset($meta["table"]) ? "Y" : "N") . ":" . $meta["len"]; +"#, + ); + assert_eq!(out, "integer:N:N:-1"); +} + +/// P2-16: `PDOStatement::getAttribute(Pdo\Sqlite::ATTR_READONLY_STATEMENT)` is a +/// live `sqlite3_stmt_readonly()` read — true for a SELECT, false for an INSERT +/// on the very same connection. Verified against a real PHP 8.5 CLI. +#[test] +fn test_pdo_sqlite_attr_readonly_statement_is_live() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (n INTEGER)"); +$sel = $db->prepare("SELECT n FROM t"); +$ins = $db->prepare("INSERT INTO t VALUES (1)"); +$selRo = $sel->getAttribute(\Pdo\Sqlite::ATTR_READONLY_STATEMENT) ? "T" : "F"; +$insRo = $ins->getAttribute(\Pdo\Sqlite::ATTR_READONLY_STATEMENT) ? "T" : "F"; +echo $selRo . ":" . $insRo; +"#, + ); + assert_eq!(out, "T:F"); +} + +/// `Pdo\Sqlite::loadExtension()` throws a PDOException when an extension cannot be +/// loaded (a nonexistent path here), exercising the method and its error path +/// without needing a real extension library. +#[test] +fn test_pdo_sqlite_load_extension_error() { + let out = compile_and_run( + r#"loadExtension("/nonexistent/elephc_missing_ext.so"); + echo "no-throw"; +} catch (\PDOException $e) { + echo "caught"; +} +"#, + ); + assert_eq!(out, "caught"); +} + +/// Pdo\Sqlite::openBlob reads a BLOB cell through bounded incremental slices. The +/// fixture stores a 3-byte BLOB with an embedded NUL (`x'610062'` = "a\0b") directly +/// through SQL so the read path is exercised independently of parameter binding, +/// then asserts the streamed bytes match and opening a missing row returns false. +#[test] +fn test_pdo_sqlite_open_blob() { + let out = compile_and_run( + r#"exec("CREATE TABLE imgs (id INTEGER PRIMARY KEY, body BLOB)"); +$db->exec("INSERT INTO imgs (id, body) VALUES (1, x'610062')"); +$s = $db->openBlob("imgs", "body", 1); +$content = stream_get_contents($s); +$ok = (strlen($content) === 3 && $content === ("a" . chr(0) . "b")) ? "ok" : "bad"; +$missing = $db->openBlob("imgs", "body", 999); +echo $ok . ":" . (($missing === false) ? "false" : "leak"); +"#, + ); + assert_eq!(out, "ok:false"); +} + +/// `Pdo\Sqlite::openBlob()` exposes the native fixed-size stream semantics: the +/// default handle is read-only, OPEN_READWRITE permits in-place writes that are +/// immediately visible to SQL, seeking/stat report the BLOB cursor and size, and +/// an extending write fails without changing the cell. +#[test] +fn test_pdo_sqlite_open_blob_readwrite_seek_and_fixed_size() { + let out = compile_and_run( + r#"exec("CREATE TABLE imgs (id INTEGER PRIMARY KEY, body BLOB)"); +$db->exec("INSERT INTO imgs (id, body) VALUES (1, x'544553542054455354')"); + +$ro = $db->openBlob("imgs", "body", 1); +$readOnly = fwrite($ro, "X") === false; +fclose($ro); + +$rw = $db->openBlob("imgs", "body", 1, "main", \Pdo\Sqlite::OPEN_READWRITE); +$written = fwrite($rw, "ABCD"); +$tell = ftell($rw); +$seek = fseek($rw, 0); +$body = stream_get_contents($rw); +$size = fstat($rw)["size"]; +$extend = fwrite($rw, "!") === false ? "fixed" : "bad"; +$stored = $db->query("SELECT hex(body) FROM imgs")->fetchColumn(); +fclose($rw); +echo ($readOnly ? "ro" : "bad") . ":" . $written . ":" . $tell . ":" . $seek . ":" . $body + . ":" . $size . ":" . $extend . ":" . $stored; +"#, + ); + assert_eq!(out, "ro:4:4:0:ABCD TEST:9:fixed:414243442054455354"); +} + +/// PDOStatement::debugDumpParams writes the SQL (with its byte length) and the +/// bound-parameter count to stdout. +#[test] +fn test_pdo_statement_debug_dump_params() { + let out = compile_and_run( + r#"prepare("SELECT 1"); +$stmt->debugDumpParams(); +"#, + ); + assert_eq!(out, "SQL: [8] SELECT 1\nParams: 0\n"); +} + +/// F-STMT-12: a POSITIONAL bind emits php-src's `Key: Position #:` block. +/// The expected bytes are derived from php-src's own format strings (pdo_stmt.c: +/// `"Key: Position #" ZEND_ULONG_FMT ":\n"` then `"paramno=" ZEND_LONG_FMT +/// "\nname=[%zd] \"%.*s\"\nis_param=%d\nparam_type=%d\n"`), NOT from elephc's output: +/// `paramno` is 0-based (php stores `paramno - 1`), a positional bind has an EMPTY name +/// (`name=[0] ""`), `is_param` is 1, and `param_type` echoes the caller's type verbatim +/// (PARAM_INT = 1). Note the two spaces after `Params:` — php-src's literal spacing. +#[test] +fn test_pdo_statement_debug_dump_params_positional_bind() { + let out = compile_and_run( + r#"prepare("SELECT ?"); +$stmt->bindValue(1, 5, PDO::PARAM_INT); +$stmt->debugDumpParams(); +"#, + ); + assert_eq!( + out, + "SQL: [8] SELECT ?\nParams: 1\nKey: Position #0:\nparamno=0\nname=[0] \"\"\nis_param=1\nparam_type=1\n" + ); +} + +/// F-STMT-12: a NAMED bind emits php-src's `Key: Name: [] :name` block, and the +/// `name=` line repeats the placeholder QUOTED with its byte length. `:b` is 2 bytes, and +/// php-src leaves a named param's `paramno` at -1 until the first execute-time +/// normalization hook resolves it; this dump intentionally happens before execute(). +#[test] +fn test_pdo_statement_debug_dump_params_named_bind() { + let out = compile_and_run( + r#"prepare("SELECT :b"); +$stmt->bindValue(':b', 'x'); +$stmt->debugDumpParams(); +"#, + ); + assert_eq!( + out, + "SQL: [9] SELECT :b\nParams: 1\nKey: Name: [2] :b\nparamno=-1\nname=[2] \":b\"\nis_param=1\nparam_type=2\n" + ); +} + +/// Rebinding one PDO parameter replaces its visible debug entry, matching the +/// `bound_params` hash used by php-src while preserving last-value-wins execution. +#[test] +fn test_pdo_statement_debug_dump_params_rebind_replaces_entry() { + let out = compile_and_run( + r#"prepare("SELECT :v"); +$stmt->bindValue("v", "first", PDO::PARAM_STR); +$stmt->bindValue(":v", 7, PDO::PARAM_INT); +$stmt->execute(); +$stmt->debugDumpParams(); +"#, + ); + assert_eq!( + out, + "SQL: [9] SELECT :v\nParams: 1\nKey: Name: [2] :v\nparamno=0\nname=[2] \":v\"\nis_param=1\nparam_type=1\n" + ); +} + +/// F-STMT-12: php-src stamps PDO_PARAM_STR (2) on EVERY element of an `execute($params)` +/// array, whatever the PHP value's type — an integer bound this way still dumps as +/// `param_type=2`. This pins the split between the type elephc DISPATCHES on (recorded +/// separately, 1 for an int) and the type php REPORTS here, so the internal dispatch tag +/// can never leak back into the dump. +#[test] +fn test_pdo_statement_debug_dump_params_execute_array_is_param_str() { + let out = compile_and_run( + r#"prepare("SELECT ?"); +$stmt->execute([5]); +$stmt->debugDumpParams(); +"#, + ); + assert_eq!( + out, + "SQL: [8] SELECT ?\nParams: 1\nKey: Position #0:\nparamno=0\nname=[0] \"\"\nis_param=1\nparam_type=2\n" + ); +} + +/// F-STMT-13: `$stmt->queryString` is readable but never overwritable — php-src guards it +/// with a custom property-write handler (`dbstmt_prop_write`), so an assignment is a +/// catchable Error rather than a silent overwrite of the SQL the statement reports. elephc +/// declares it `readonly` (assigned once in the constructor) to get there. +/// +/// This pins both the concrete and `PDOStatement|bool` receiver shapes. Both raise a +/// catchable Error; the text is PHP's generic readonly message rather than pdo_stmt.c's +/// custom wording, but the exception class and write rejection match. +/// +/// In both shapes the SQL is protected, which is the point of the finding. The test also +/// proves `readonly` does not break the constructor's OWN write — a regression there would +/// break EVERY PDOStatement construction, not just this assignment. +#[test] +fn test_pdo_statement_query_string_is_readonly() { + let out = compile_and_run( + r#"prepare("SELECT 1"); +echo $stmt->queryString; +// Union receiver (PDOStatement|bool): readonly validation still applies. +try { + $stmt->queryString = "DROP TABLE t"; + echo "|union:no-error"; +} catch (Error $e) { + echo "|union:" . $e->getMessage(); +} +echo "|" . $stmt->queryString; +// Narrowed receiver: catchable Error, value preserved. +if ($stmt instanceof PDOStatement) { + try { + $stmt->queryString = "DROP TABLE t"; + echo "|narrowed:no-error"; + } catch (Error $e) { + echo "|narrowed:" . $e->getMessage(); + } + echo "|" . $stmt->queryString; +} +"#, + ); + assert_eq!( + out, + "SELECT 1|union:Cannot modify readonly property PDOStatement::$queryString|SELECT 1\ + |narrowed:Cannot modify readonly property PDOStatement::$queryString|SELECT 1" + ); +} + +/// F-STMT-17: setFetchMode(FETCH_COLUMN, ) raises a TypeError BEFORE the `< 0` +/// range check, mirroring php-src (pdo_stmt.c:1767-70). The check is STRICT — zend never +/// juggles this variadic argument, so a numeric string throws just like an array does. +/// +/// The message carries NO argument name: zend cannot name a variadic parameter, so php +/// prints `Argument #2 must be of type int, string given`, not `Argument #2 ($args)`. +#[test] +fn test_pdo_statement_set_fetch_mode_column_rejects_non_int() { + let out = compile_and_run( + r#"prepare("SELECT 1"); +try { + $stmt->setFetchMode(PDO::FETCH_COLUMN, "abc"); + echo "no-error"; +} catch (TypeError $e) { + echo $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "PDOStatement::setFetchMode(): Argument #2 must be of type int, string given" + ); +} + +/// Verifies PDOException::getCode() reports the SQLSTATE string while errorInfo retains +/// the native driver code, matching php-src's PDO-specific exception initialization. +#[test] +fn test_pdo_exception_get_code_is_sqlstate() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$db->exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT UNIQUE)"); +$db->exec("INSERT INTO t (v) VALUES ('a')"); +try { + $db->exec("INSERT INTO t (v) VALUES ('a')"); + echo "no-error"; +} catch (PDOException $e) { + echo "code=" . $e->getCode(); + echo "|sqlstate=" . $e->errorInfo[0]; + echo "|native=" . $e->errorInfo[1]; + echo "|prev=" . ($e->previous === null ? "null" : "set"); +} +"#, + ); + assert_eq!(out, "code=23000|sqlstate=23000|native=19|prev=null"); +} + +/// Verifies PDOException preserves and returns its previous Throwable chain entry. +#[test] +fn test_pdo_exception_get_previous_returns_stored_throwable() { + let out = compile_and_run( + r#"getPrevious(); +echo ($actual === $previous ? "same" : "different") . "|" . $error->getCode(); +"#, + ); + assert_eq!(out, "same|17"); +} + +/// Pdo\Pgsql::escapeIdentifier is a pure string transform (PQescapeIdentifier +/// semantics: double interior double-quotes, wrap in double-quotes) that touches no +/// connection, so it is exercised via a non-connecting Pdo\Pgsql subclass — proving +/// both the transform and dispatch of an own method declared on a namespaced +/// subclass, without a live PostgreSQL server. +#[test] +fn test_pdo_pgsql_escape_identifier() { + let out = compile_and_run( + r#"inTxn/$this->conn, which + // the empty constructor never initialized. + public function __construct(string $dsn = "", ?string $username = null, ?string $password = null, ?array $options = null) {} + public function __destruct() {} +} +$pg = new FakePg(); +echo $pg->escapeIdentifier('my"col') . "|" . $pg->escapeIdentifier('plain'); +"#, + ); + assert_eq!(out, "\"my\"\"col\"|\"plain\""); +} + +/// Tier-D `Pdo\Sqlite::createCollation`: a compiled-PHP closure comparator drives a +/// custom `COLLATE` ordering. Here the comparator reverses the natural string order, +/// so `ORDER BY name COLLATE REV` returns the rows descending — proving the whole +/// path: the callable is decomposed into (descriptor, adapter) pointers, registered +/// via SQLite `pApp`, and re-entered by `__rt_pdo_call_collation` for each comparison. +#[test] +fn test_pdo_sqlite_create_collation_reverse_order() { + let out = compile_and_run( + r#"createCollation("REV", function($a, $b) { + return strcmp($b, $a); +}); +$db->exec("CREATE TABLE t (name TEXT)"); +$db->exec("INSERT INTO t (name) VALUES ('banana'), ('apple'), ('cherry')"); +$rows = $db->query("SELECT name FROM t ORDER BY name COLLATE REV")->fetchAll(PDO::FETCH_NUM); +$out = ""; +foreach ($rows as $r) { $out .= $r[0] . ","; } +echo $out; +"#, + ); + assert_eq!(out, "cherry,banana,apple,"); +} + +/// Tier-D `Pdo\Sqlite::createCollation`: TWO collations registered on one connection +/// coexist and each keeps its own comparator. This is the direct disproof of "problem +/// C" (the old single process-global callback slot, last-write-wins): if both +/// registrations shared one slot, the reverse query would sort ascending (the +/// last-registered comparator). Each registration threads its own descriptor through +/// SQLite `pApp`, so `REV` stays descending while `NAT` sorts ascending. +#[test] +fn test_pdo_sqlite_create_collation_two_coexist() { + let out = compile_and_run( + r#"createCollation("REV", function($a, $b) { + return strcmp($b, $a); +}); +$db->createCollation("NAT", function($a, $b) { + return strcmp($a, $b); +}); +$db->exec("CREATE TABLE t (name TEXT)"); +$db->exec("INSERT INTO t (name) VALUES ('banana'), ('apple'), ('cherry')"); +$rev = ""; +foreach ($db->query("SELECT name FROM t ORDER BY name COLLATE REV")->fetchAll(PDO::FETCH_NUM) as $r) { + $rev .= $r[0] . ","; +} +$nat = ""; +foreach ($db->query("SELECT name FROM t ORDER BY name COLLATE NAT")->fetchAll(PDO::FETCH_NUM) as $r) { + $nat .= $r[0] . ","; +} +echo $rev . "|" . $nat; +"#, + ); + assert_eq!(out, "cherry,banana,apple,|apple,banana,cherry,"); +} + +/// Tier-D exception firewall: a collation comparator that `throw`s must not unwind +/// past the C boundary (SQLite's VDBE + the Rust bridge frame). The adapter's +/// `setjmp` firewall catches the `throw`, treats the comparison as equal (SQLite's +/// `xCompare` has no error channel), and lets the query complete. The program must +/// finish (no deadlock/hang/crash from a `longjmp` over C frames) and the connection +/// must remain usable — a following query still returns the correct count. +#[test] +fn test_pdo_sqlite_create_collation_throwing_comparator_does_not_hang() { + let out = compile_and_run( + r#"createCollation("BOOM", function($a, $b) { + throw new Exception("boom"); +}); +$db->exec("CREATE TABLE t (name TEXT)"); +$db->exec("INSERT INTO t (name) VALUES ('x'), ('y'), ('z')"); +$rows = $db->query("SELECT name FROM t ORDER BY name COLLATE BOOM")->fetchAll(PDO::FETCH_NUM); +$count = $db->query("SELECT COUNT(*) FROM t")->fetchColumn(); +echo count($rows) . ":" . $count; +"#, + ); + assert_eq!(out, "3:3"); +} + +/// Tier-D `Pdo\Sqlite::createFunction`: a compiled-PHP closure drives a scalar SQL +/// function. Integer arguments box as Mixed ints, cross into the callable, and the +/// integer return is decoded back through `__rt_pdo_call_scalar` into +/// `sqlite3_result_int64` — proving the whole path (decompose → pApp → adapter → box +/// args → invoke → decode int return). +#[test] +fn test_pdo_sqlite_create_function_int_args() { + let out = compile_and_run( + r#"createFunction("myadd", function($a, $b) { + return $a + $b; +}, 2); +echo $db->query("SELECT myadd(3, 4)")->fetchColumn(); +"#, + ); + assert_eq!(out, "7"); +} + +/// Verifies PHP 8.4's legacy pdo_sqlite methods remain installed directly on +/// `PDO`, including callback rooting for scalar, aggregate, and collation hooks. +#[test] +fn test_pdo_sqlite_legacy_driver_extension_methods() { + let out = compile_and_run( + r#"sqliteCreateFunction("twice", function($value) { return $value * 2; }, 1); +$db->sqliteCreateAggregate( + "mysum", + function($context, $row, $value) { return $context === null ? $value : $context + $value; }, + function($context, $row) { return $context; }, + 1 +); +$db->sqliteCreateCollation("REVERSE", function($left, $right) { return strcmp($right, $left); }); +$db->exec("CREATE TABLE t(v TEXT)"); +$db->exec("INSERT INTO t VALUES ('a'), ('c'), ('b')"); +echo $db->query("SELECT twice(4)")->fetchColumn(), "|"; +echo $db->query("SELECT mysum(length(v)) FROM t")->fetchColumn(), "|"; +echo $db->query("SELECT v FROM t ORDER BY v COLLATE REVERSE LIMIT 1")->fetchColumn(); +"#, + ); + assert_eq!(out, "8|3|c"); +} + +/// Tier-D `Pdo\Sqlite::createFunction`: a TEXT argument round-trips byte-exactly (the +/// adapter deep-copies SQLite's transient buffer while boxing tag-1 strings), and a +/// string return is staged through `elephc_pdo_udf_stash_bytes` and handed back to +/// SQLite with `sqlite3_result_text` — proving the string arg + string result path. +#[test] +fn test_pdo_sqlite_create_function_string_roundtrip() { + let out = compile_and_run( + r#"createFunction("myecho", function($s) { + return $s . "!"; +}, 1); +echo $db->query("SELECT myecho('hi')")->fetchColumn(); +"#, + ); + assert_eq!(out, "hi!"); +} + +/// Tier-D `Pdo\Sqlite::createFunction`: a zero-argument function exercises the empty +/// (header-only) args-array path, and a float return proves the f64 bit-pattern +/// survives the box/unbox round-trip (carried in the integer lo register, written to +/// `ElephcResult.f`, dispatched to `sqlite3_result_double`). +#[test] +fn test_pdo_sqlite_create_function_float_and_zero_args() { + let out = compile_and_run( + r#"createFunction("myval", function() { + return 2.5; +}, 0); +echo $db->query("SELECT myval()")->fetchColumn(); +"#, + ); + assert_eq!(out, "2.5"); +} + +/// Tier-D `Pdo\Sqlite::createFunction`: TWO scalar functions on one connection coexist, +/// each keeping its own callable. This is the direct disproof of "problem C" (a single +/// process-global callback slot, last-write-wins) for scalar functions: if both shared +/// one slot, `f1()` would return 22 (the last-registered body). Each registration +/// threads its own descriptor through SQLite `pApp`, so `f1` stays 11 and `f2` is 22. +#[test] +fn test_pdo_sqlite_create_function_two_coexist() { + let out = compile_and_run( + r#"createFunction("f1", function() { return 11; }, 0); +$db->createFunction("f2", function() { return 22; }, 0); +echo $db->query("SELECT f1()")->fetchColumn() . "," . $db->query("SELECT f2()")->fetchColumn(); +"#, + ); + assert_eq!(out, "11,22"); +} + +/// SQLite callbacks may register another callback and prepare/step a nested +/// statement on the same connection without deadlocking bridge handle tables. +#[test] +fn test_pdo_sqlite_callback_allows_nested_query_and_registration() { + let out = compile_and_run( + r#"createFunction("outer_fn", function($value) use ($db) { + $db->createFunction("inner_fn", function() { return 40; }, 0); + $nested = $db->query("SELECT inner_fn()"); + return $nested->fetchColumn() + $value; +}, 1); +echo $db->query("SELECT outer_fn(2)")->fetchColumn(), "|"; +echo $db->query("SELECT inner_fn()")->fetchColumn(); +"#, + ); + assert_eq!(out, "42|40"); +} + +/// Tier-D `Pdo\Sqlite::createFunction`: SQLite passes a different storage class per row, +/// so one registration must re-box each argument by its per-row type. An identity +/// function over a column holding an INTEGER then a TEXT value must return each with its +/// original type preserved (int→`result_int64`, text→`result_text`), yielding `5,str,`. +#[test] +fn test_pdo_sqlite_create_function_per_row_dynamic_typing() { + let out = compile_and_run( + r#"createFunction("ident", function($x) { return $x; }, 1); +$db->exec("CREATE TABLE t (v)"); +$db->exec("INSERT INTO t (v) VALUES (5), ('str')"); +$out = ""; +foreach ($db->query("SELECT ident(v) FROM t ORDER BY rowid")->fetchAll(PDO::FETCH_NUM) as $r) { + $out .= $r[0] . ","; +} +echo $out; +"#, + ); + assert_eq!(out, "5,str,"); +} + +/// Tier-D `Pdo\Sqlite::createFunction`: a SQL NULL argument boxes as PHP null (Mixed +/// tag 8), and a PHP null return decodes to `sqlite3_result_null`. Verified in SQL +/// (`ident(v) IS NULL`) so the round-trip is checked without a PHP-side null compare: +/// the null survives the box → invoke → decode path, so the count is 1. +#[test] +fn test_pdo_sqlite_create_function_null_roundtrip() { + let out = compile_and_run( + r#"createFunction("ident", function($x) { return $x; }, 1); +$db->exec("CREATE TABLE t (v)"); +$db->exec("INSERT INTO t (v) VALUES (NULL)"); +echo $db->query("SELECT COUNT(*) FROM t WHERE ident(v) IS NULL")->fetchColumn(); +"#, + ); + assert_eq!(out, "1"); +} + +/// Tier-D exception firewall (scalar path): a user function that `throw`s must not +/// unwind past the C boundary (SQLite's VDBE + the Rust bridge frame). The adapter's +/// `setjmp` firewall catches the `throw` and reports `ElephcResult.tag = -1`, which the +/// bridge turns into a `sqlite3_result_error`; the statement fails but the program must +/// finish (no deadlock/hang/crash from a `longjmp` over C frames) and the connection +/// must remain usable — a following query still evaluates correctly. +#[test] +fn test_pdo_sqlite_create_function_throwing_does_not_hang() { + let out = compile_and_run( + r#"createFunction("boom", function() { + throw new Exception("bang"); +}, 0); +try { + $db->exec("SELECT boom()"); +} catch (\Exception $e) { +} +echo "done:" . $db->query("SELECT 1 + 1")->fetchColumn(); +"#, + ); + assert_eq!(out, "done:2"); +} + +/// Tier-D `Pdo\Sqlite::createAggregate`: an integer-accumulating aggregate (sum). The +/// step callback receives the running accumulator + row number + row value and returns +/// the new accumulator (row-number-seeded on the first row, so no null arithmetic); the +/// finalize callback returns it. Proves the whole aggregate path: per-group +/// accumulator threaded through `sqlite3_aggregate_context`, `__rt_pdo_call_agg_step` +/// per row, `__rt_pdo_call_agg_final` once, and correct row-number threading (PHP +/// bug-for-bug parity: the shared row counter is pre-incremented, so `$rownumber` is +/// `1` on the first step, per `sqlite_driver.c`'s `++agg_context->row`). +#[test] +fn test_pdo_sqlite_create_aggregate_sum() { + let out = compile_and_run( + r#"createAggregate("mysum", + function($ctx, $row, $v) { if ($row == 1) { return $v; } return $ctx + $v; }, + function($ctx, $row) { return $ctx; } +); +$db->exec("CREATE TABLE t (v)"); +$db->exec("INSERT INTO t (v) VALUES (1), (2), (3), (4)"); +echo $db->query("SELECT mysum(v) FROM t")->fetchColumn(); +"#, + ); + assert_eq!(out, "10"); +} + +/// Tier-D `Pdo\Sqlite::createAggregate`: a STRING-accumulating aggregate (concat). This +/// is the refcount-critical test — the accumulator is a boxed-Mixed PHP string that +/// must survive being stored in the group slot and passed back into the next step +/// across every row (incref-new-before-release-old). A refcount error frees it early +/// (corrupt output) or leaks it. Correct output proves the ownership protocol. +#[test] +fn test_pdo_sqlite_create_aggregate_string_concat() { + let out = compile_and_run( + r#"createAggregate("myconcat", + function($ctx, $row, $v) { if ($row == 1) { return $v; } return $ctx . $v; }, + function($ctx, $row) { return $ctx; } +); +$db->exec("CREATE TABLE t (v)"); +$db->exec("INSERT INTO t (v) VALUES ('a'), ('b'), ('c'), ('d')"); +echo $db->query("SELECT myconcat(v) FROM t")->fetchColumn(); +"#, + ); + assert_eq!(out, "abcd"); +} + +/// Tier-D `Pdo\Sqlite::createAggregate`: `GROUP BY` gives each group its own +/// accumulator. SQLite allocates a distinct `sqlite3_aggregate_context` per group, so +/// the two groups accumulate independently — the direct proof that the per-group slot +/// (not a single shared accumulator) carries the state. Expects `a=3,b=30,`. +#[test] +fn test_pdo_sqlite_create_aggregate_group_by() { + let out = compile_and_run( + r#"createAggregate("mysum", + function($ctx, $row, $v) { if ($row == 1) { return $v; } return $ctx + $v; }, + function($ctx, $row) { return $ctx; } +); +$db->exec("CREATE TABLE t (g, v)"); +$db->exec("INSERT INTO t (g, v) VALUES ('a', 1), ('a', 2), ('b', 10), ('b', 20)"); +$out = ""; +foreach ($db->query("SELECT g, mysum(v) FROM t GROUP BY g ORDER BY g")->fetchAll(PDO::FETCH_NUM) as $r) { + $out .= $r[0] . "=" . $r[1] . ","; +} +echo $out; +"#, + ); + assert_eq!(out, "a=3,b=30,"); +} + +/// Tier-D `Pdo\Sqlite::createAggregate`: an empty group calls finalize exactly once +/// with NO prior step (`sqlite3_aggregate_context(ctx, 0)` returns NULL → a null +/// accumulator and a shared row counter that starts at 0). PHP's finalize call +/// pre-increments that same shared counter (`++agg_context->row`) even though `xStep` +/// never ran, so the finalize here — which returns the row number — yields `1`, not +/// `0`, proving the empty-group path and the bug-for-bug row-count threading. +#[test] +fn test_pdo_sqlite_create_aggregate_empty_group() { + let out = compile_and_run( + r#"createAggregate("mycount", + function($ctx, $row, $v) { return $ctx; }, + function($ctx, $row) { return $row; } +); +$db->exec("CREATE TABLE t (v)"); +echo $db->query("SELECT mycount(v) FROM t")->fetchColumn(); +"#, + ); + assert_eq!(out, "1"); +} + +/// Tier-D `Pdo\Sqlite::createAggregate` — VALUE-PIN the `$rownumber` sequence +/// (v1 §6 open gap). The other aggregate tests only branch on `$row == 1` to +/// detect the first step; none captures the actual value `$row` takes each call. +/// Here the accumulator records every `$row` it is handed, so the output is the +/// literal sequence: SQLite pre-increments the shared `agg_context->row` at the +/// START of each `xStep` AND once more at `xFinal` (mirroring php-src's +/// `++agg_context->row`), so four rows yield step values 1,2,3,4 and finalize +/// sees 5 — never 0-based, and finalize is one past the last step. This is the +/// exact bug-for-bug row threading `test_..._empty_group` pins for the 0-step +/// case, extended to the multi-step case. +#[test] +fn test_pdo_sqlite_create_aggregate_rownumber_sequence() { + let out = compile_and_run( + r#"createAggregate("rowseq", + function($ctx, $row, $v) { if ($row == 1) { return "" . $row; } return $ctx . "-" . $row; }, + function($ctx, $row) { return $ctx . "|" . $row; } +); +$db->exec("CREATE TABLE t (v)"); +$db->exec("INSERT INTO t (v) VALUES (10), (20), (30), (40)"); +echo $db->query("SELECT rowseq(v) FROM t")->fetchColumn(); +"#, + ); + assert_eq!(out, "1-2-3-4|5"); +} + +/// Tier-D exception firewall (aggregate step): a step callback that `throw`s must not +/// unwind across SQLite's VDBE + the Rust bridge frame. The step adapter's firewall +/// catches the longjmp, preserves the accumulator (so finalize still frees it), and +/// signals the throw; the bridge raises a SQL error. The program must finish and the +/// connection stay usable. +#[test] +fn test_pdo_sqlite_create_aggregate_throwing_step_does_not_hang() { + let out = compile_and_run( + r#"createAggregate("boom", + function($ctx, $row, $v) { throw new Exception("step boom"); }, + function($ctx, $row) { return $ctx; } +); +$db->exec("CREATE TABLE t (v)"); +$db->exec("INSERT INTO t (v) VALUES (1), (2)"); +try { + $db->exec("SELECT boom(v) FROM t"); +} catch (\Exception $e) { +} +echo "done:" . $db->query("SELECT 1 + 1")->fetchColumn(); +"#, + ); + assert_eq!(out, "done:2"); +} + +/// Tier-D exception firewall (aggregate finalize): a finalize callback that `throw`s is +/// caught by the finalize adapter's firewall, reported as an error result, and — since +/// finalize is terminal — the accumulator is still freed (no leak/dangling before +/// SQLite frees the group block). The program must finish and the connection stay +/// usable. +#[test] +fn test_pdo_sqlite_create_aggregate_throwing_final_does_not_hang() { + let out = compile_and_run( + r#"createAggregate("boomf", + function($ctx, $row, $v) { if ($row == 1) { return $v; } return $ctx + $v; }, + function($ctx, $row) { throw new Exception("final boom"); } +); +$db->exec("CREATE TABLE t (v)"); +$db->exec("INSERT INTO t (v) VALUES (1), (2)"); +try { + $db->exec("SELECT boomf(v) FROM t"); +} catch (\Exception $e) { +} +echo "done:" . $db->query("SELECT 1 + 1")->fetchColumn(); +"#, + ); + assert_eq!(out, "done:2"); +} + +/// P0-5: `prepare($sql, $options)`'s two-arg form compiles and runs; `$options` is +/// stored into the statement's attribute map with no behavioral effect, so the +/// prepared statement still binds and fetches normally. +#[test] +fn test_pdo_prepare_with_options() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada')"); +$stmt = $db->prepare("SELECT name FROM t WHERE id = ?", []); +$stmt->execute([1]); +echo $stmt->fetch(PDO::FETCH_ASSOC)["name"]; +"#, + ); + assert_eq!(out, "Ada"); +} + +/// P0-6: `query($sql, PDO::FETCH_ASSOC)` applies the fetch mode to the returned +/// statement via `setFetchMode()`, so a mode-less `fetch()` on it returns assoc rows. +#[test] +fn test_pdo_query_with_fetch_mode() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada')"); +$row = $db->query("SELECT id, name FROM t", PDO::FETCH_ASSOC)->fetch(); +echo $row["id"] . ":" . $row["name"] . "|" . (isset($row[0]) ? "both" : "assoc"); +"#, + ); + assert_eq!(out, "1:Ada|assoc"); +} + +/// F-STMT-01: the 3-arg form is php-src's real one — `fetch(int $mode, int +/// $cursorOrientation, int $cursorOffset)` — and `fetch(FETCH_ASSOC, +/// PDO::FETCH_ORI_NEXT, 0)` behaves exactly like the 1-arg `fetch(FETCH_ASSOC)`. +/// (This test used to pass `null` into position 2 under the fabricated +/// `$classOrObject` signature, which is a TypeError against real PDO.) +/// +/// Both trailing arguments are accepted and inert: every driver here opens a +/// FORWARD-ONLY cursor (`PDO::CURSOR_FWDONLY`; `ATTR_CURSOR` is inert), and php-src +/// likewise ignores the orientation on one. On a `CURSOR_SCROLL` statement real PHP +/// WOULD honor `FETCH_ORI_FIRST`/`LAST`/`PRIOR`/`ABS`/`REL` and seek — that divergence +/// is a property of the cursor, not of this signature. +#[test] +fn test_pdo_fetch_three_arg_form() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada'), (2, 'Bob')"); +$stmt = $db->query("SELECT id, name FROM t ORDER BY id"); +$row = $stmt->fetch(PDO::FETCH_ASSOC, PDO::FETCH_ORI_NEXT, 0); +echo $row["name"]; + +// Identical to the 1-arg form: the orientation does not consume or skip a row, so +// the SECOND fetch still lands on the next row rather than re-reading the first. +$next = $stmt->fetch(PDO::FETCH_ASSOC); +echo "|" . $next["name"]; +"#, + ); + assert_eq!(out, "Ada|Bob"); +} + +/// P1-6: the common legacy `bindParam($p, $v, PDO::PARAM_STR, $maxLength[, $driverOptions])` +/// 4- and 5-arg idioms compile and run; the extra length/driver-option hints are +/// accepted by every driver (PDO_OCI consumes `$maxLength` for output buffers). +#[test] +fn test_pdo_bind_param_extended_arity() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$ins = $db->prepare("INSERT INTO t (id, name) VALUES (?, ?)"); +$id = 1; +$name = "Ada"; +$ins->bindParam(1, $id, PDO::PARAM_INT, 0); +$ins->bindParam(2, $name, PDO::PARAM_STR, 4000, null); +$ins->execute(); +echo $db->query("SELECT name FROM t WHERE id = 1")->fetchColumn(); +"#, + ); + assert_eq!(out, "Ada"); +} + +/// Verifies `fetchAll(PDO::FETCH_CLASS, Row::class, [...])` forwards the complete +/// constructor-argument array through dynamic construction for every fetched row. +#[test] +fn test_pdo_fetch_all_class_with_ctor_args() { + let out = compile_and_run( + r#"prefix = $prefix; } +} + +$db = new PDO("sqlite::memory:"); +$db->exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada'), (2, 'Bob')"); +$rows = $db->query("SELECT id, name FROM t ORDER BY id")->fetchAll(PDO::FETCH_CLASS, Row::class, ["row-"]); +echo count($rows) . ":" . $rows[0]->prefix . $rows[0]->name . ":" . $rows[1]->prefix . $rows[1]->name; +"#, + ); + assert_eq!(out, "2:row-Ada:row-Bob"); +} + +/// Verifies `fetchObject()` forwards arbitrary constructor arguments instead of +/// silently constructing the dynamic class with an empty argument list. +#[test] +fn test_pdo_fetch_object_forwards_constructor_args() { + let out = compile_and_run( + r#"label = $prefix . $id; + } +} +$db = new PDO("sqlite::memory:"); +$stmt = $db->query("SELECT 'Ada' AS name"); +$row = $stmt->fetchObject(FetchObjectCtorRow::class, ["row-", 7]); +echo $row->label . ":" . $row->name; +"#, + ); + assert_eq!(out, "row-7:Ada"); +} + +/// Verifies default `FETCH_CLASS` hydrates properties before construction while +/// `FETCH_PROPS_LATE` deliberately runs the constructor first. +#[test] +fn test_pdo_fetch_class_hydration_order_matches_props_late_flag() { + let out = compile_and_run( + r#"seen = $prefix . $this->name; + $this->name = "constructor"; + } +} +$db = new PDO("sqlite::memory:"); +$early = $db->query("SELECT 'Ada' AS name")->fetchAll(PDO::FETCH_CLASS, HydrationOrderRow::class, ["default:"])[0]; +$late = $db->query("SELECT 'Ada' AS name")->fetchAll(PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE, HydrationOrderRow::class, ["late:"])[0]; +echo $early->seen . "/" . $early->name . "|" . $late->seen . "/" . $late->name; +"#, + ); + assert_eq!(out, "default:Ada/constructor|late:initial/Ada"); +} + +/// Verifies `PDO::query()` forwards its complete variadic fetch-mode tail, including +/// a heterogeneous constructor-argument array, into the statement fetch configuration. +#[test] +fn test_pdo_query_forwards_variadic_fetch_mode_arguments() { + let out = compile_and_run( + r#"label = $prefix . $number . ":" . $this->name; + } +} +$db = new PDO("sqlite::memory:"); +$stmt = $db->query("SELECT 'Ada' AS name", PDO::FETCH_CLASS, QueryCtorRow::class, ["row-", 9]); +$row = $stmt->fetch(); +echo $row->label; +"#, + ); + assert_eq!(out, "row-9:Ada"); +} + +/// Verifies `getIterator()` returns the adapter used by PDOStatement's IteratorAggregate contract. +#[test] +fn test_pdo_statement_get_iterator() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada'), (2, 'Bob')"); +$stmt = $db->query("SELECT id, name FROM t ORDER BY id"); +$stmt->setFetchMode(PDO::FETCH_ASSOC); +$out = ""; +foreach ($stmt->getIterator() as $row) { + $out .= $row["name"] . ","; +} +echo $out; +"#, + ); + assert_eq!(out, "Ada,Bob,"); +} + +/// Verifies PDOStatement exposes PHP 8.4's IteratorAggregate relationship, not Iterator. +#[test] +fn test_pdo_statement_iterator_aggregate_relationship() { + let out = compile_and_run( + r#"query("SELECT 1"); +echo ($stmt instanceof IteratorAggregate ? "aggregate" : "no"), "|"; +echo ($stmt instanceof Iterator ? "iterator" : "no"); +"#, + ); + assert_eq!(out, "aggregate|no"); +} + +/// P1-13: `Pdo\Sqlite::createFunction`'s parameters were renamed to match the PHP +/// stub (`$function_name`, `$num_args`), so a PHP-valid named-argument call now +/// resolves (it previously failed against `$name`/`$numArgs`). +#[test] +fn test_pdo_sqlite_create_function_named_args() { + let out = compile_and_run( + r#"createFunction(function_name: "myadd", callback: function ($a, $b) { + return $a + $b; +}, num_args: 2); +echo $db->query("SELECT myadd(3, 4)")->fetchColumn(); +"#, + ); + assert_eq!(out, "7"); +} + +/// P0-2: `FETCH_NAMED` groups duplicate-named columns into a numerically-indexed +/// array under that one key instead of the last value silently overwriting the +/// first (verified against real PHP: `SELECT 1 a, 2 a` => `["a" => [1, 2]]`, no +/// numeric keys injected). A uniquely-named column stays a plain scalar. +#[test] +fn test_pdo_fetch_named_groups_duplicate_columns() { + let out = compile_and_run( + r#"query("SELECT 1 a, 2 a")->fetch(PDO::FETCH_NAMED); +echo count($row) . ":" . implode(",", $row["a"]) . ";"; +$row2 = $db->query("SELECT 1 a, 2 b")->fetch(PDO::FETCH_NAMED); +echo count($row2) . ":" . $row2["a"] . ":" . $row2["b"]; +"#, + ); + assert_eq!(out, "1:1,2;2:1:2"); +} + +/// P0-2: a third occurrence of the same column name keeps appending to the +/// group instead of only ever holding two entries. +#[test] +fn test_pdo_fetch_named_groups_three_duplicates() { + let out = compile_and_run( + r#"query("SELECT 1 a, 2 a, 3 a")->fetch(PDO::FETCH_NAMED); +echo count($row) . ":" . implode(",", $row["a"]); +"#, + ); + assert_eq!(out, "1:1,2,3"); +} + +/// P0-3/P2: `fetch(PDO::FETCH_FUNC)` is rejected — real PHP restricts +/// FETCH_FUNC to `fetchAll()` and this prelude fails the same way (as a +/// `ValueError`, matching php-src's `zend_value_error("Can only use +/// PDO::FETCH_FUNC in PDOStatement::fetchAll()")`) instead of returning the +/// silent BOTH-shaped fallthrough. +#[test] +fn test_pdo_fetch_func_on_fetch_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$stmt = $db->query("SELECT id FROM t"); +try { + $stmt->fetch(PDO::FETCH_FUNC); + echo "no-throw"; +} catch (ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:Can only use PDO::FETCH_FUNC in PDOStatement::fetchAll()" + ); +} + +/// Verifies `FETCH_FUNC` invokes the callback once per row with positional column arguments. +#[test] +fn test_pdo_fetch_func_on_fetch_all_invokes_callback() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a INTEGER, b TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'x'), (2, 'y')"); +$_rows = $db->query("SELECT a, b FROM t")->fetchAll(PDO::FETCH_FUNC, function ($a, $b) { + return $a . ":" . $b; +}); +foreach ($_rows as $_row) { + echo $_row, ";"; +} +"#, + ); + assert_eq!(out, "1:x;2:y;"); +} + +/// Verifies `FETCH_FUNC` resolves a function name carried through its boxed Mixed callback slot. +#[test] +fn test_pdo_fetch_func_accepts_string_callable() { + let out = compile_and_run( + r#"query("SELECT 'x' UNION ALL SELECT 'yz'") + ->fetchAll(PDO::FETCH_FUNC, "pdo_fetch_func_label"); +foreach ($rows as $row) { + echo $row . ";"; +} +"#, + ); + assert_eq!(out, "value=x;value=yz;"); +} + +/// Verifies `FETCH_FUNC` accepts both instance and static callable-array forms in Mixed. +#[test] +fn test_pdo_fetch_func_accepts_callable_arrays() { + let out = compile_and_run( + r#"query("SELECT 'a'") + ->fetchAll(PDO::FETCH_FUNC, $instanceCallback); +$staticRows = $db->query("SELECT 'b'") + ->fetchAll(PDO::FETCH_FUNC, $staticCallback); +echo $instanceRows[0] . "|" . $staticRows[0] + . "|" . $instanceCallback[1] . "|" . $staticCallback[0]; +"#, + ); + assert_eq!( + out, + "instance=a|static=b|instanceLabel|PdoFetchFuncFormatter" + ); +} + +/// Verifies `FETCH_FUNC` resolves an invokable object carried through its Mixed callback slot. +#[test] +fn test_pdo_fetch_func_accepts_invokable_object() { + let out = compile_and_run( + r#"query("SELECT 'object'") + ->fetchAll(PDO::FETCH_FUNC, new PdoFetchFuncInvoker()); +echo $rows[0]; +"#, + ); + assert_eq!(out, "invoked=object"); +} + +/// Verifies `FETCH_LAZY` exposes a reusable statement-backed `PDORow` with property and offset reads. +#[test] +fn test_pdo_fetch_lazy_returns_reused_pdo_row() { + let out = compile_and_run( + r#"query("SELECT 1 AS id, 'a' AS label UNION ALL SELECT 2, 'b'"); +$first = $stmt->fetch(PDO::FETCH_LAZY); +if (!($first instanceof PDORow)) { throw new Exception("missing first row"); } +PDORow $typedFirst = $first; +echo "PDORow:", $typedFirst->id, ":", $typedFirst[1], ":", $typedFirst->queryString, "|"; +$second = $stmt->fetch(PDO::FETCH_LAZY); +if (!($second instanceof PDORow)) { throw new Exception("missing second row"); } +PDORow $typedSecond = $second; +echo ($typedFirst === $typedSecond ? "same" : "different"), ":", $typedFirst->id, ":", $typedSecond[1], "|"; +} catch (Throwable $error) { + echo "unexpected:", get_class($error), ":", $error->getMessage(); +} +"#, + ); + assert_eq!( + out, + "PDORow:1:a:SELECT 1 AS id, 'a' AS label UNION ALL SELECT 2, 'b'|same:2:b|" + ); +} + +/// Verifies the PDORow refresh hook is private outside PDOStatement::fetch(). +#[test] +fn test_pdo_row_refresh_hook_is_not_public() { + let out = compile_and_run( + r#"query("SELECT 1 AS id")->fetch(PDO::FETCH_LAZY); +if (!($row instanceof PDORow)) { throw new Exception("missing row"); } +PDORow $typedRow = $row; +try { + $typedRow->__elephcRefresh([], []); + echo "public"; +} catch (Error $error) { + echo "private"; +} +"#, + ); + assert_eq!(out, "private"); +} + +/// P2-11: `fetchColumn()` with an index at or beyond `columnCount()` throws a +/// `ValueError` once a row actually exists to check the index against +/// (verified against real PHP; an out-of-range index against an EMPTY result +/// still just returns `false`, matching the "no more rows" case). +#[test] +fn test_pdo_fetch_column_out_of_range_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a INTEGER)"); +$db->exec("INSERT INTO t VALUES (1)"); +$stmt = $db->query("SELECT a FROM t"); +try { + $stmt->fetchColumn(1); + echo "no-throw"; +} catch (ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!(out, "threw:Invalid column index"); +} + +/// P2-11: a negative column index gets its own distinct PHP-matching message. +#[test] +fn test_pdo_fetch_column_negative_index_throws() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a INTEGER)"); +$db->exec("INSERT INTO t VALUES (1)"); +$stmt = $db->query("SELECT a FROM t"); +try { + $stmt->fetchColumn(-1); + echo "no-throw"; +} catch (ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!(out, "threw:Column index must be greater than or equal to 0"); +} + +/// P2-11: an out-of-range index against an EMPTY result set still just returns +/// `false` (no row exists to validate the index against), matching real PHP. +#[test] +fn test_pdo_fetch_column_out_of_range_on_empty_result_returns_false() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a INTEGER)"); +$stmt = $db->query("SELECT a FROM t"); +$v = $stmt->fetchColumn(5); +echo $v === false ? "false" : "other"; +"#, + ); + assert_eq!(out, "false"); +} + +/// Verifies `bindColumn()` retains caller storage and writes each successful fetch. +#[test] +fn test_pdo_bind_column_updates_durable_reference() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, label TEXT)"); + $db->exec("INSERT INTO t VALUES (1, 'a'), (2, 'b')"); + $stmt = $db->query("SELECT id, label FROM t ORDER BY id"); + $stmt->bindColumn(1, $id, PDO::PARAM_INT); + $stmt->bindColumn("label", $label); + echo ($stmt->fetch(PDO::FETCH_BOUND) ? "row" : "none") . ":" . $id . ":" . $label; + $stmt->fetch(PDO::FETCH_ASSOC); + echo "|" . $id . ":" . $label; +} +run(); +"#, + ); + assert_eq!(out, "row:1:a|2:b"); +} + +/// Verifies PDOStatement subclasses preserve bindColumn's durable by-reference destination. +#[test] +fn test_pdo_statement_subclass_bind_column_updates_durable_reference() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_STATEMENT_CLASS, [BoundStatement::class]); +$stmt = $db->query("SELECT 7"); +mixed $value = null; +$stmt->bindColumn(1, $value, PDO::PARAM_INT); +$stmt->fetch(PDO::FETCH_BOUND); +echo get_class($stmt) . ":" . $value; +"#, + ); + assert_eq!(out, "BoundStatement:7"); +} + +/// Verifies rebinding one output column replaces its previous destination like php-src's hash. +#[test] +fn test_pdo_bind_column_replaces_existing_destination() { + let out = compile_and_run( + r#"query("SELECT 42 AS answer"); +mixed $first = "first"; +mixed $second = "second"; +$stmt->bindColumn(1, $first, PDO::PARAM_INT); +$stmt->bindColumn(1, $second, PDO::PARAM_INT); +$stmt->fetch(PDO::FETCH_BOUND); +echo $first . "|" . $second; +"#, + ); + assert_eq!(out, "first|42"); +} + +/// `fetch(PDO::FETCH_BOUND)` advances and reports availability with no bindings too. +#[test] +fn test_pdo_fetch_bound_advances_cursor_without_throwing() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a INTEGER)"); +$db->exec("INSERT INTO t VALUES (1)"); +$db->exec("INSERT INTO t VALUES (2)"); +$stmt = $db->query("SELECT a FROM t"); +$first = $stmt->fetch(PDO::FETCH_BOUND); +$second = $stmt->fetch(PDO::FETCH_BOUND); +$third = $stmt->fetch(PDO::FETCH_BOUND); +echo ($first === true ? "true" : "other") . ":" + . ($second === true ? "true" : "other") . ":" + . ($third === false ? "false" : "other"); +"#, + ); + assert_eq!(out, "true:true:false"); +} + +/// P1-3: `fetch(PDO::FETCH_BOUND)` against an EMPTY result set returns `false` +/// on the very first call (no row is ever available to advance to). +#[test] +fn test_pdo_fetch_bound_on_empty_result_returns_false() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a INTEGER)"); +$stmt = $db->query("SELECT a FROM t"); +$r = $stmt->fetch(PDO::FETCH_BOUND); +echo $r === false ? "false" : "other"; +"#, + ); + assert_eq!(out, "false"); +} + +/// P1-1: `FETCH_CLASS | FETCH_PROPS_LATE` is honored, not rejected — verified +/// against php-src's `pdo_stmt_verify_mode`: PROPS_LATE is never even tested in +/// that function (it is not a rejection reason for ANY base mode), and a base +/// mode of FETCH_CLASS jumps straight to its own switch case regardless. +/// elephc's FETCH_CLASS is already unconditionally ctor-first, so the flag is +/// free to accept. +/// +/// F-STMT-09: this now ALSO pins that flag masking does not OVER-reject or silently +/// drop the target. `setFetchMode()`'s gates used to test the RAW `$mode`, which is +/// false the moment any high-bit flag is OR-ed in, so this exact call matched no gate +/// at all and its class name was dropped on the floor by the storage block — leaving a +/// statement in FETCH_CLASS mode with NO target, which then quietly fetched `stdClass` +/// rows. Asserting `instanceof Row` (not merely "an object") is what catches that. +#[test] +fn test_pdo_fetch_class_with_props_late_flag_works() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada')"); +$stmt = $db->query("SELECT id, name FROM t"); +$stmt->setFetchMode(PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE, Row::class); +$row = $stmt->fetch(); +echo (($row instanceof Row) ? "Row" : "not-row") . ":" . $row->id . ":" . $row->name; +"#, + ); + assert_eq!(out, "Row:1:Ada"); +} + +/// PHP 8.4 accepts `FETCH_PROPS_LATE` with a non-CLASS base mode as a silent +/// no-op; PHP 8.5 tightens this combination in the versioned regression above. +#[test] +fn test_pdo_php84_fetch_props_late_flag_with_other_mode_is_not_rejected() { + let out = compile_and_run_with_php_version( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$stmt = $db->query("SELECT id FROM t"); +$row = $stmt->fetch(PDO::FETCH_ASSOC | PDO::FETCH_PROPS_LATE); +echo $row["id"]; +"#, + PhpVersion::Php84, + ); + assert_eq!(out, "1"); +} + +/// F-STMT-02: `FETCH_CLASS | FETCH_CLASSTYPE` is accepted (php-src's +/// `pdo_stmt_verify_mode` switches directly to the FETCH_CLASS case, skipping the +/// CLASSTYPE rejection check for that base mode) — and it is now REAL, where this test +/// used to assert a fabricated version of it. +/// +/// CLASSTYPE means the class is NOT the one configured on the statement: it is READ +/// FROM COLUMN 0'S RUNTIME VALUE, row by row, so ONE result set can hydrate a DIFFERENT +/// class per row. php-src (`pdo_stmt.c:805-829`) does three things the old code did none +/// of: it `fetch_value()`s column 0, it `zend_lookup_class()`es that string, and it +/// hydrates from COLUMN 1 ONWARD — column 0 was CONSUMED as the type tag and must not +/// also land in a property (`fetch_value(stmt, &val, i++, NULL)` literally advances the +/// column cursor past it). +/// +/// The old test passed a literal `Row::class`, asserted the literal won, and asserted +/// column 0 was still assigned as a property — i.e. it pinned the flag being IGNORED. +/// An explicit class argument is now a `ValueError` (see the setFetchMode test below), +/// so the class can ONLY come from the data. Here two rows name two different classes +/// and each is instantiated from its own row's column 0, which no "literal class wins" +/// implementation could produce. +/// The consumption half is asserted by DECLARING the column-0 property (`$kind`) on both +/// classes and proving it never receives column 0's value. Both properties are given an +/// explicit `= null` DEFAULT, and that default is what makes the assertion legal: PDO never +/// assigns `$kind` (column 0 having been consumed as the type tag), so without a default it +/// would be an UNINITIALIZED TYPED PROPERTY, and reading one is an `Error` — "Typed property +/// Cat::$kind must not be accessed before initialization" — in real PHP 8.4 exactly as in +/// elephc. (An earlier draft of this test omitted the defaults and died on that Error, which +/// looked like a PDO bug and was not one.) Defaulted, the slot reads back as null, so +/// comparing against the class-name STRING distinguishes precisely one thing: column 0 +/// having leaked into a property, which is the defect under test. +#[test] +fn test_pdo_fetch_class_with_classtype_flag_reads_class_from_column_zero() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (kind TEXT, id INTEGER)"); +$db->exec("INSERT INTO t VALUES ('Cat', 1), ('Dog', 2)"); +$stmt = $db->query("SELECT kind, id FROM t ORDER BY id"); +$stmt->setFetchMode(PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPE); + +$first = $stmt->fetch(); +$second = $stmt->fetch(); + +// Column 0 ('kind') is CONSUMED as the class name: its value must NOT also be +// assigned to the same-named property. `id` (column 1) is the only column hydrated. +$catKind = ($first->kind === "Cat") ? "leaked" : "consumed"; +$dogKind = ($second->kind === "Dog") ? "leaked" : "consumed"; + +echo (($first instanceof Cat) ? "Cat" : "not-cat") . ":" . $first->id . ":" . $catKind + . "|" . (($second instanceof Dog) ? "Dog" : "not-dog") . ":" . $second->id . ":" . $dogKind; +"#, + ); + assert_eq!(out, "Cat:1:consumed|Dog:2:consumed"); +} + +/// F-STMT-02, the fallback arm: a column-0 value naming NO class hydrates a `stdClass` +/// instead of failing — php-src's `zend_lookup_class()`-found-nothing branch resolves to +/// `zend_standard_class_def` (`pdo_stmt.c:805-829`). Column 0 is STILL consumed: the +/// bogus type tag does not become a property of the fallback object either. +/// +/// The stdClass fallback is implemented WITHOUT `class_exists()`: elephc's +/// `class_exists()` is an AOT constant-fold (`lower_class_like_exists` requires a CONST +/// STRING operand) and does not compile against a runtime string — the only kind that can +/// ever reach here. The dynamic `new $name()` IS the existence probe instead: it lowers +/// to `DynamicObjectNewMixed`, whose runtime miss path (`__rt_new_by_name`) returns PHP +/// `null` for a name in no class table. This test is what proves that miss path is +/// actually reached rather than, say, constructing a garbage object. +/// +/// Column 0 is consumed here too, but that is NOT asserted through this fixture: the +/// fallback is a `stdClass`, whose columns arrive as DYNAMIC properties, so probing for +/// the absence of `kind` would mean reading a never-set dynamic property (or reaching for +/// `isset()`/`get_object_vars()`, neither of which this suite exercises anywhere). The +/// exclusion is pinned by the typed-class sibling above — both arms call the same +/// `assignColumnsFrom($obj, 1, $count)` with the same start index — and, independently, by +/// the FETCH_GROUP/FETCH_UNIQUE shape tests, which assert the consumed key column is +/// absent from every row. +#[test] +fn test_pdo_fetch_classtype_unknown_class_falls_back_to_stdclass() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (kind TEXT, id INTEGER)"); +$db->exec("INSERT INTO t VALUES ('NoSuchClass', 7)"); +$stmt = $db->query("SELECT kind, id FROM t"); +$stmt->setFetchMode(PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPE); +$row = $stmt->fetch(); + +echo (($row instanceof stdClass) ? "stdClass" : "other") + . ":" . (($row instanceof Cat) ? "cat" : "not-cat") + . ":" . $row->id; +"#, + ); + assert_eq!(out, "stdClass:not-cat:7"); +} + +/// P1-1: `FETCH_CLASSTYPE` combined with any OTHER base mode is rejected with a +/// `ValueError`, matching php-src's `pdo_stmt_verify_mode` default-case check +/// (`zend_argument_value_error(1, "must use PDO::FETCH_CLASSTYPE with +/// PDO::FETCH_CLASS")`) verified against the PHP-8.4 branch of php-src. +#[test] +fn test_pdo_php84_fetch_classtype_flag_throws_with_non_class_mode() { + let out = compile_and_run_with_php_version( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$stmt = $db->query("SELECT id FROM t"); +try { + $stmt->fetch(PDO::FETCH_ASSOC | PDO::FETCH_CLASSTYPE); + echo "no-throw"; +} catch (ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + PhpVersion::Php84, + ); + assert_eq!( + out, + "threw:PDOStatement::fetch(): Argument #1 ($mode) must use PDO::FETCH_CLASSTYPE with PDO::FETCH_CLASS" + ); +} + +/// F-STMT-09: `setFetchMode(FETCH_CLASS|FETCH_CLASSTYPE, 'Foo')` is REJECTED. Under +/// CLASSTYPE the class name comes from column 0's VALUE at fetch time, so an explicit +/// class argument is not merely redundant — it is a CONTRADICTION, and php-src rejects +/// the combination outright (`pdo_stmt.c:1783-1790`: the CLASSTYPE arm takes its class +/// from the data and raises `zend_argument_count_error` the moment a variadic class +/// argument accompanies it). This prelude used to ACCEPT the combo and quietly discard +/// the argument. +/// +/// elephc has no `ArgumentCountError` class, so — per this file's existing convention for +/// the sibling arity gates — the rejection is a `ValueError` carrying php-src's literal +/// message text. +#[test] +fn test_pdo_set_fetch_mode_classtype_with_explicit_class_is_rejected() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$stmt = $db->query("SELECT id FROM t"); +try { + $stmt->setFetchMode(PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPE, Foo::class); + echo "no-throw"; +} catch (ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:PDOStatement::setFetchMode() expects exactly 1 argument for the fetch mode provided, 2 given" + ); +} + +/// F-STMT-09, the OVER-rejection negative control: masking the flags off must not make +/// the gates reject a LEGITIMATE flagged call. `setFetchMode(FETCH_CLASS|FETCH_PROPS_LATE, +/// 'Row')` is accepted, returns true, AND the class name is actually STORED — the bug this +/// pins is that the old raw-`$mode` storage test silently DROPPED the target for any +/// flagged mode, leaving the statement in FETCH_CLASS mode with no class and quietly +/// fetching `stdClass` rows. Only fetching a row and checking `instanceof Row` catches it, +/// so the round-trip is asserted rather than just the return value. +/// +/// (PROPS_LATE is never a rejection reason in php-src's `pdo_stmt_verify_mode` for ANY +/// base mode — unlike CLASSTYPE above, which is one for every base mode except +/// FETCH_CLASS.) +#[test] +fn test_pdo_set_fetch_mode_props_late_flag_keeps_the_class_target() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (5)"); +$stmt = $db->query("SELECT id FROM t"); +$ok = $stmt->setFetchMode(PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE, Row::class); +$row = $stmt->fetch(); +echo (($ok === true) ? "true" : "false") + . ":" . (($row instanceof Row) ? "Row" : "not-row") + . ":" . $row->id; +"#, + ); + assert_eq!(out, "true:Row:5"); +} + +/// F-STMT-04: `fetch(PDO::FETCH_INTO)` with NO object configured used to hand back a +/// fresh, anonymous `stdClass` — a silent success that threw the caller's row into an +/// object they never see. php-src raises HY000 "No fetch-into object specified." +/// (`pdo_stmt.c:864-871`, via `pdo_raise_impl_error`, hence errMode-aware). FETCH_INTO +/// without a target is not a mode, it is a mistake: the target is the entire point of the +/// mode. +#[test] +fn test_pdo_fetch_into_without_target_raises_hy000_under_exception() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$stmt = $db->query("SELECT id FROM t"); +try { + $stmt->fetch(PDO::FETCH_INTO); + echo "no-throw"; +} catch (PDOException $e) { + echo "threw:" . $e->getMessage() . "|" . $e->errorInfo[0]; +} +"#, + ); + assert_eq!( + out, + "threw:SQLSTATE[HY000]: General error: No fetch-into object specified.|HY000" + ); +} + +/// F-STMT-04, the errMode-aware other half: the same targetless `FETCH_INTO` is QUIET +/// under `ERRMODE_SILENT` and returns `false` — `pdo_raise_impl_error` respects the error +/// mode. `false` (not an object) is the load-bearing assertion: the old code's silent +/// fresh-stdClass is exactly what this must never be again. +/// +/// `errorCode()` is deliberately NOT asserted here. `failCode()` mirrors +/// `pdo_raise_impl_error`'s errMode dispatch but does NOT write the statement's driver +/// error slots (there was no driver-level failure to read one from), so `errorCode()` +/// still reads the driver's "00000" — a synthetic-error/driver-error asymmetry that is a +/// property of `failCode()` generally, not of this fetch mode, and is not this test's +/// subject. +#[test] +fn test_pdo_fetch_into_without_target_returns_false_under_silent() { + let out = compile_and_run( + r#" PDO::ERRMODE_SILENT]); +$db->exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t (id) VALUES (1)"); +$stmt = $db->query("SELECT id FROM t"); +$row = $stmt->fetch(PDO::FETCH_INTO); +echo ($row === false) ? "false" : "object"; +"#, + ); + assert_eq!(out, "false"); +} + +/// F-STMT-15: `FETCH_GROUP` with `FETCH_ASSOC`. Column 0 is CONSUMED as the grouping key +/// and each key maps to a LIST of every row that carried it, in result order (php-src's +/// `do_fetch` with a non-NULL `return_all`: `add_next_index_zval` into the group's array, +/// `pdo_stmt.c:1072-1086`). These used to throw "not yet supported". +/// +/// `count($r)` is the consumption assertion and it is the whole point: the query selects +/// THREE columns and every grouped row must contain exactly TWO. A row that still carried +/// its own key column would be the classic silently-wrong result — plausible-looking, and +/// wrong in the way nobody notices. +/// +/// Non-numeric keys throughout: elephc's array keeps an integer-LOOKING group key a STRING +/// key where PHP folds it back to an int (the one documented divergence in +/// `fetchAllGrouped()`), so a numeric key would be pinning an array-semantics gap rather +/// than PDO behavior. +#[test] +fn test_pdo_fetch_all_group_with_assoc_consumes_column_zero() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (kind TEXT, name TEXT, n INTEGER)"); +$db->exec("INSERT INTO t VALUES ('fruit', 'apple', 1), ('fruit', 'banana', 2), ('veg', 'carrot', 3)"); +$out = $db->query("SELECT kind, name, n FROM t ORDER BY n")->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_ASSOC); + +$s = ""; +foreach ($out as $k => $rows) { + $s .= $k . "[" . count($rows) . "]="; + foreach ($rows as $r) { + // count($r) == 2: 'kind' (column 0) became the KEY and is gone from the row. + $s .= count($r) . ":" . $r["name"] . ":" . $r["n"] . ","; + } + $s .= ";"; +} +echo $s; +"#, + ); + assert_eq!(out, "fruit[2]=2:apple:1,2:banana:2,;veg[1]=2:carrot:3,;"); +} + +/// Verifies FETCH_GROUP applies PHP array-key normalization after converting the +/// grouping column to string: canonical in-range decimal integers become int keys, +/// while leading zeros, `-0`, and overflowing values remain string keys. +#[test] +fn test_pdo_fetch_all_group_normalizes_integer_looking_keys() { + let out = compile_and_run( + r#"query("SELECT '1' AS k, 'a' AS v UNION ALL SELECT '01', 'b' UNION ALL SELECT '-1', 'c' UNION ALL SELECT '-0', 'd' UNION ALL SELECT '9223372036854775808', 'e'") + ->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_COLUMN); +foreach ($rows as $key => $values) { + echo gettype($key) . ":" . $key . "=" . $values[0] . ";"; +} +"#, + ); + assert_eq!( + out, + "integer:1=a;string:01=b;integer:-1=c;string:-0=d;string:9223372036854775808=e;" + ); +} + +/// F-STMT-15: `FETCH_GROUP` with `FETCH_NUM`. Same consumption, plus the subtler half: +/// the surviving columns are RE-INDEXED FROM 0, not left at their original offsets. +/// php-src walks the row with TWO cursors — the column index `i` (which starts at 1, the +/// key having been taken) and the output index `idx` (which starts at 0) — so the first +/// column AFTER the key lands at `[0]`. A row that kept its original offsets would start +/// at `[1]` and have NO `[0]` at all, which is why `$r[0]` is read here rather than +/// counted. +#[test] +fn test_pdo_fetch_all_group_with_num_reindexes_from_zero() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (kind TEXT, name TEXT, n INTEGER)"); +$db->exec("INSERT INTO t VALUES ('fruit', 'apple', 1), ('veg', 'carrot', 3)"); +$out = $db->query("SELECT kind, name, n FROM t ORDER BY n")->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_NUM); + +$s = ""; +foreach ($out as $k => $rows) { + foreach ($rows as $r) { + $s .= $k . "=" . count($r) . ":" . $r[0] . ":" . $r[1] . ";"; + } +} +echo $s; +"#, + ); + assert_eq!(out, "fruit=2:apple:1;veg=2:carrot:3;"); +} + +/// F-STMT-15: `FETCH_UNIQUE` maps each key to ONE row, LAST WRITE WINS on a duplicate key +/// — php-src uses `zend_symtable_update`, a plain overwrite that neither detects nor +/// complains about a duplicate (`pdo_stmt.c:1072-1086`). FETCH_UNIQUE is 0x30000 and thus +/// a SUPERSET of FETCH_GROUP (0x10000), not a sibling of it: a `& 0x10000` test is true +/// for BOTH, so getting the last-wins shape (rather than a one-element LIST per key) is +/// what proves the 0x30000 mask is actually being applied. +/// +/// 'fruit' appears twice; the SECOND row must win outright, and the value must be the ROW +/// ITSELF, not a list containing it. +#[test] +fn test_pdo_fetch_all_unique_is_last_write_wins() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (kind TEXT, name TEXT)"); +$db->exec("INSERT INTO t VALUES ('fruit', 'apple'), ('veg', 'carrot'), ('fruit', 'banana')"); +$out = $db->query("SELECT kind, name FROM t ORDER BY rowid")->fetchAll(PDO::FETCH_UNIQUE | PDO::FETCH_ASSOC); + +$s = count($out) . "|"; +foreach ($out as $k => $r) { + // $r is the ROW (a 1-element assoc array), NOT a list of rows: count($r) == 1 and + // $r["name"] reads directly. 'kind' is consumed as the key, so it is not in $r. + $s .= $k . "=" . count($r) . ":" . $r["name"] . ";"; +} +echo $s; +"#, + ); + assert_eq!(out, "2|fruit=1:banana;veg=1:carrot;"); +} + +/// F-STMT-15: the classic `FETCH_GROUP|FETCH_COLUMN` idiom — `[kind => [name, name, …]]` +/// — and the defaulting rule that makes it work. php-src's `fetchAll()` spells it out: +/// `stmt->fetch.column = arg2 ? … : (how & PDO_FETCH_GROUP ? 1 : 0)`, i.e. with NO explicit +/// index and GROUP set, the VALUE column defaults to **1**, not the usual 0. Column 0 is +/// already spoken for as the grouping key, so defaulting the value to it too would return +/// the useless `[kind => [kind, kind, …]]`. +/// +/// The second half pins that an EXPLICIT index still overrides the default (column 2 here), +/// so the defaulting is a fallback and not a hardcode. +#[test] +fn test_pdo_fetch_all_group_column_idiom_defaults_value_column_to_one() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (kind TEXT, name TEXT, n INTEGER)"); +$db->exec("INSERT INTO t VALUES ('fruit', 'apple', 1), ('fruit', 'banana', 2), ('veg', 'carrot', 3)"); + +$byName = $db->query("SELECT kind, name, n FROM t ORDER BY n") + ->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_COLUMN); +$s = ""; +foreach ($byName as $k => $names) { + $s .= $k . "=" . implode(",", $names) . ";"; +} + +// An explicit index overrides the GROUP default of 1: take column 2 ('n') instead. +$byN = $db->query("SELECT kind, name, n FROM t ORDER BY n") + ->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_COLUMN, 2); +$s .= "|"; +foreach ($byN as $k => $ns) { + $s .= $k . "=" . implode(",", $ns) . ";"; +} +echo $s; +"#, + ); + assert_eq!( + out, + "fruit=apple,banana;veg=carrot;|fruit=1,2;veg=3;" + ); +} + +/// O(n^2)->O(n) regression: `FETCH_GROUP`'s append branch used to read the bucket out of +/// `$_groups`, push onto the local copy, then write it back — with the bucket sitting at +/// refcount 2 (the map slot + the local) across every one of those pushes, so each one +/// COW-cloned the whole bucket. The fix `unset()`s the map slot before the push so the +/// bucket is refcount 1 and mutates in place. This drives ONE key through 60 rows — enough +/// to have made the O(n^2) clone-per-row path expensive — interleaved with two single-row +/// keys, so a bug in the unset()/reinsert sequence (wrong bucket, dropped rows, corrupted +/// key order) would show up as either a short/garbled 'big' group or a scrambled key order. +/// +/// Asserts, precisely: (1) the 60-row group contains ALL 60 rows, in RESULT order; (2) the +/// two incidental single-row groups are untouched by the big group's growth; (3) all three +/// keys come out in FIRST-SEEN order ('a', then 'big', then 'mid', then 'b' — 'mid' is +/// inserted in the MIDDLE of the 'big' run, after 'big' is already first-seen, so its +/// presence there also proves the interleaved unset()/reinsert of 'big' never disturbs an +/// unrelated key's own bucket). +#[test] +fn test_pdo_fetch_all_group_large_group_is_on_via_unset_reinsert() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (kind TEXT, name TEXT, n INTEGER)"); +$ins = $db->prepare("INSERT INTO t (kind, name, n) VALUES (?, ?, ?)"); +$ins->execute(["a", "afirst", 0]); +for ($i = 1; $i <= 30; $i++) { + $ins->execute(["big", "r" . $i, $i]); +} +$ins->execute(["mid", "midrow", 31]); +for ($i = 31; $i <= 60; $i++) { + $ins->execute(["big", "r" . $i, $i + 1]); +} +$ins->execute(["b", "blast", 62]); + +$out = $db->query("SELECT kind, name FROM t ORDER BY n")->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_COLUMN); + +$s = ""; +foreach ($out as $k => $vals) { + $s .= $k . "[" . count($vals) . "]=" . implode(",", $vals) . ";"; +} +echo $s; +"#, + ); + let big_rows: Vec = (1..=60).map(|i| format!("r{i}")).collect(); + let expected = format!( + "a[1]=afirst;big[60]={};mid[1]=midrow;b[1]=blast;", + big_rows.join(",") + ); + assert_eq!(out, expected); +} + +/// F-STMT-15: the two combinations that are REFUSED LOUDLY rather than faked, both because +/// column 0 is already consumed as the grouping key and something else wants it too. +/// +/// FETCH_CLASSTYPE also reads column 0 (as the class name); php-src resolves the collision +/// by consuming TWO columns (key from 0, class from 1, properties from 2) — a shape no +/// caller of this prelude has ever been able to ask for, so it is refused rather than +/// invented. FETCH_NAMED under GROUP has no meaningful per-group row either (its +/// duplicate-column-name grouping is a second, orthogonal reshaping). Loud beats silently +/// wrong: the caller gets an error naming the combination, not a plausible array of the +/// wrong shape. +#[test] +fn test_pdo_fetch_all_group_refuses_classtype_and_named() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (kind TEXT, name TEXT)"); +$db->exec("INSERT INTO t VALUES ('fruit', 'apple')"); + +try { + $db->query("SELECT kind, name FROM t") + ->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPE); + echo "no-throw"; +} catch (PDOException $e) { + echo $e->getMessage(); +} + +echo "|"; + +try { + $db->query("SELECT kind, name FROM t")->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_NAMED); + echo "no-throw"; +} catch (PDOException $e) { + echo $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + concat!( + "PDO::FETCH_CLASSTYPE is not supported with PDO::FETCH_GROUP or PDO::FETCH_UNIQUE", + "|", + "PDO::FETCH_GROUP and PDO::FETCH_UNIQUE are not supported with this fetch mode" + ) + ); +} + +/// P1-11 (best-effort): `ATTR_STRINGIFY_FETCHES` stringifies INTEGER and FLOAT +/// columns but leaves NULL untouched, matching real PHP, and is threaded from +/// the connection to a statement the same way `defaultFetchMode` already is (a +/// `prepare()`-time snapshot). +#[test] +fn test_pdo_stringify_fetches() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_STRINGIFY_FETCHES, true); +$db->exec("CREATE TABLE t (a INTEGER, b REAL, c TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 2.5, 'x')"); +$row = $db->query("SELECT a, b, c FROM t")->fetch(PDO::FETCH_ASSOC); +echo (is_string($row["a"]) ? "str" : "notstr") . ":" . $row["a"] . ","; +echo (is_string($row["b"]) ? "str" : "notstr") . ":" . $row["b"] . ","; +echo (is_string($row["c"]) ? "str" : "notstr") . ":" . $row["c"] . ","; +$row2 = $db->query("SELECT NULL a")->fetch(PDO::FETCH_ASSOC); +echo $row2["a"] === null ? "null" : "notnull"; +"#, + ); + assert_eq!(out, "str:1,str:2.5,str:x,null"); +} + +/// P1-11: `ATTR_STRINGIFY_FETCHES` defaults to off — an ordinary connection +/// still returns native int/float types, so this slice's addition is opt-in. +#[test] +fn test_pdo_stringify_fetches_off_by_default() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a INTEGER)"); +$db->exec("INSERT INTO t VALUES (1)"); +$row = $db->query("SELECT a FROM t")->fetch(PDO::FETCH_ASSOC); +echo is_int($row["a"]) ? "int" : "notint"; +"#, + ); + assert_eq!(out, "int"); +} + +/// P1-4: a connect failure (here, `sqlite:` pointed at a directory that does not +/// exist, so SQLite cannot create the database file) throws with a populated +/// 3-element `errorInfo` and a `"SQLSTATE[]: ..."`-prefixed message, matching +/// the standard try/catch-around-`new PDO` classification idiom +/// (`$e->errorInfo[0]`). Verified against a real PHP 8.5 CLI: sqlite connect +/// failures report SQLSTATE `HY000`. +#[test] +fn test_pdo_connect_failure_populates_error_info() { + let out = compile_and_run( + r#"errorInfo; + echo (str_starts_with($e->getMessage(), "SQLSTATE[HY000]:") ? "prefixed" : "unprefixed") . ","; + echo $info[0] . "," . ($info[1] === null ? "null" : "notnull") . ","; + echo (strlen($info[2]) > 0 ? "has-message" : "no-message"); +} +"#, + ); + assert_eq!(out, "prefixed,HY000,null,has-message"); +} + +/// A driver error's `getMessage()` carries the php-src "SQLSTATE[%s]: %s: %d %s" +/// shape — the SQLSTATE class DESCRIPTION ("General error", "Integrity constraint +/// violation") and the native driver code, not just the raw driver message. +/// Verified byte-for-byte against a real PHP 8.4 CLI + pdo_sqlite. errorInfo keeps +/// the raw [state, native, message] triple, unchanged. +#[test] +fn test_pdo_driver_error_message_has_description_and_native_code() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$db->exec("CREATE TABLE t (id INT PRIMARY KEY)"); +$db->exec("INSERT INTO t VALUES (1)"); +try { $db->query("SELECT * FROM nope"); } catch (PDOException $e) { echo $e->getMessage(), "\n"; } +try { $db->exec("INSERT INTO t VALUES (1)"); } +catch (PDOException $e) { echo $e->getMessage(), "|", $e->errorInfo[0], "|", $e->errorInfo[1], "|", $e->errorInfo[2]; } +"#, + ); + assert_eq!( + out, + "SQLSTATE[HY000]: General error: 1 no such table: nope\n\ + SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: t.id\ + |23000|19|UNIQUE constraint failed: t.id" + ); +} + +/// P1-4 (unrecognized-driver case, kept distinct from a known-driver connect +/// failure): `new PDO("bogus:...")` still throws PHP's bare "could not find +/// driver" shape — no `SQLSTATE[...]` prefix — because no driver ever attempted +/// the connection. Verified against a real PHP 8.5 CLI. The source explicitly +/// passes `errorInfo: null` at this throw site too (see the constructor's +/// comment), but asserting `$e->errorInfo === null` here is NOT reliable: a +/// pre-existing, general elephc bug (reproduced in isolation, unrelated to this +/// fix — outside PDO entirely) corrupts an untyped/Mixed constructor parameter's +/// `null` call sites when OTHER call sites for the same parameter, elsewhere in +/// the same class, pass array literals whose element types differ (PDOException's +/// `$errorInfo` sees both `[string, null, string]` here and `[string, int, +/// string]` in `fail()`/`errorInfo()` elsewhere in this very class) — so this test +/// only pins the message shape, not the errorInfo value. +#[test] +fn test_pdo_connect_unrecognized_driver_error_info_stays_null() { + let out = compile_and_run( + r#"getMessage(), "SQLSTATE[") ? "prefixed" : "unprefixed"); +} +"#, + ); + assert_eq!(out, "unprefixed"); +} + +/// P2-17: `PDO::__clone()` throws — PHP marks `PDO` uncloneable so two Zend +/// objects never share one bridge connection handle (whichever is destructed +/// first would otherwise close it out from under the survivor). elephc has no +/// `clone` operator yet (confirmed: the lexer/parser have no `clone` keyword at +/// all — `clone $x` fails to parse, and `--check` reports "Undefined function: +/// clone"), so this pins the guard method directly by invoking the magic method +/// like any other, exactly as codegen would dispatch it once `clone $pdo` support +/// lands. +#[test] +fn test_pdo_clone_throws() { + let out = compile_and_run( + r#"__clone(); + echo "no-throw"; +} catch (\Error $e) { + // Read into a local first: elephc has a pre-existing, unrelated bug where + // concatenating a string LITERAL with a caught exception's getMessage() + // result corrupts the output ONLY when that message was itself built by + // concatenation at throw time (reproducible with plain + // `throw new Exception("a" . $b);` outside any PDO code) — an intermediate + // variable sidesteps it. + $msg = $e->getMessage(); + echo "threw:" . $msg; +} +"#, + ); + assert_eq!(out, "threw:Trying to clone an uncloneable object of class PDO"); +} + +/// P2-17: `PDOStatement::__clone()` throws for the same reason as `PDO::__clone()` +/// — a shallow clone would produce a second owner of the statement handle. See +/// `test_pdo_clone_throws` for why this invokes the magic method directly rather +/// than through a `clone` expression. +#[test] +fn test_pdo_statement_clone_throws() { + let out = compile_and_run( + r#"query("SELECT 1"); +try { + $stmt->__clone(); + echo "no-throw"; +} catch (\Error $e) { + // See test_pdo_clone_throws for why this reads into a local first. + $msg = $e->getMessage(); + echo "threw:" . $msg; +} +"#, + ); + assert_eq!( + out, + "threw:Trying to clone an uncloneable object of class PDOStatement" + ); +} + +/// P2-17: cloning a driver subclass instance reports the RUNTIME class in the +/// message (`Pdo\Sqlite`, not the base `PDO`), matching a real PHP CLI exactly — +/// `__clone` is inherited from the base `PDO` class and reads `get_class($this)`. +#[test] +fn test_pdo_clone_throws_with_subclass_name() { + let out = compile_and_run( + r#"__clone(); + echo "no-throw"; +} catch (\Error $e) { + echo $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "Trying to clone an uncloneable object of class Pdo\\Sqlite" + ); +} + +/// SQLite exposes the same embedded library version as client and server, while +/// rejecting server-info and connection-status exactly like php-src's driver hook. +#[test] +fn test_pdo_get_attribute_client_version_and_connection_status() { + let out = compile_and_run( + r#" PDO::ERRMODE_SILENT]); +$clientVersion = $db->getAttribute(PDO::ATTR_CLIENT_VERSION); +$serverVersion = $db->getAttribute(PDO::ATTR_SERVER_VERSION); +$serverInfo = $db->getAttribute(PDO::ATTR_SERVER_INFO); +$connStatus = $db->getAttribute(PDO::ATTR_CONNECTION_STATUS); +echo ($clientVersion !== null && strlen((string) $clientVersion) > 0) ? "has-client-version" : "null-client-version"; +echo ","; +echo ($clientVersion === $serverVersion) ? "same-version" : "different-version"; +echo ","; +echo ($serverInfo === false) ? "unsupported-info" : "has-server-info"; +echo ","; +echo ($connStatus === false) ? "unsupported-status" : "has-connection-status"; +"#, + ); + assert_eq!( + out, + "has-client-version,same-version,unsupported-info,unsupported-status" + ); +} + +/// SQLite's attribute hook has no connection-status case and therefore reaches IM001 +/// under exception mode. +#[test] +fn test_pdo_get_attribute_connection_status_throws_for_sqlite() { + let out = compile_and_run( + r#"getAttribute(PDO::ATTR_CONNECTION_STATUS); + echo "no-throw"; +} catch (PDOException $e) { + echo $e->errorInfo[0]; +} +"#, + ); + assert_eq!(out, "IM001"); +} + +/// `prepare($sql, $options)` with two live statements taking different option-array +/// shapes must not corrupt the heap (a `foreach ($options ...)` in this ordinary frame +/// tripped the pre-existing interior-frame iterator miscompile; options are accepted +/// and ignored instead). Regression pin for the parity-audit fix. +#[test] +fn test_pdo_prepare_options_two_live_statements_no_crash() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (v)"); +$db->exec("INSERT INTO t (v) VALUES (1)"); +$s1 = $db->prepare("SELECT v FROM t"); +$s2 = $db->prepare("SELECT v FROM t", [PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY]); +$s1->execute(); +$s2->execute(); +echo $s1->fetchColumn() . "," . $s2->fetchColumn(); +"#, + ); + assert_eq!(out, "1,1"); +} + +/// `fetchAll(PDO::FETCH_COLUMN, $n)` must return column `$n`, not always column 0. +/// Regression pin: the existing FETCH_COLUMN test only used the no-argument form. +#[test] +fn test_pdo_fetch_all_column_honors_index() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a, b, c)"); +$db->exec("INSERT INTO t (a, b, c) VALUES (1, 10, 100), (2, 20, 200)"); +$col2 = $db->query("SELECT a, b, c FROM t ORDER BY a")->fetchAll(PDO::FETCH_COLUMN, 2); +echo implode(",", $col2); +"#, + ); + assert_eq!(out, "100,200"); +} + +/// `fetchAll(PDO::FETCH_COLUMN)` with no index must NOT reuse a stale index left +/// on `$this->fetchColumn` by a PRIOR `fetchAll(FETCH_COLUMN, $n)` call on the +/// same statement — it must reset to column 0, matching php-src's +/// `stmt->fetch.column = arg2 ? Z_LVAL(arg2) : (how & PDO_FETCH_GROUP ? 1 : 0)`. +/// Regression pin: the prelude's FETCH_COLUMN branch previously had no `else`, +/// leaking whatever index the earlier explicit call left behind. +#[test] +fn test_pdo_fetch_all_column_no_index_does_not_leak_prior_index() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a, b)"); +$db->exec("INSERT INTO t (a, b) VALUES (1, 10), (2, 20)"); +$stmt = $db->prepare("SELECT a, b FROM t ORDER BY a"); +$stmt->execute(); +$leaked = $stmt->fetchAll(PDO::FETCH_COLUMN, 1); +$stmt2 = $db->prepare("SELECT a, b FROM t ORDER BY a"); +$stmt2->execute(); +$reset = $stmt2->fetchAll(PDO::FETCH_COLUMN); +echo implode(",", $leaked) . "|" . implode(",", $reset); +"#, + ); + assert_eq!(out, "10,20|1,2"); +} + +/// A caught `PDOException` exposes a usable `errorInfo`: `null` for an unrecognized +/// driver (matching PHP), and an indexable `[SQLSTATE, code, message]` triple for a +/// server error so the standard `$e->errorInfo[0]` idiom works. Regression pin for the +/// `?array`-typed property fix (an untyped property corrupted across the array/null +/// call sites). +#[test] +fn test_pdo_exception_error_info_usable() { + let out = compile_and_run( + r#"errorInfo === null) ? "unrec-null" : "unrec-notnull"; +} +$db = new PDO("sqlite::memory:"); +try { + $db->query("THIS IS NOT SQL"); +} catch (\PDOException $e) { + $msg .= "," . $e->errorInfo[0]; +} +echo $msg; +"#, + ); + assert_eq!(out, "unrec-null,HY000"); +} + +/// P0-A regression: `PDO::PARAM_STR` (the default `bindValue()` type) preserves an +/// embedded NUL byte end to end. Before the v20 ABI fix, `elephc_pdo_bind_text` +/// bound via SQLite's strlen-based `-1` sentinel, so `"AB\x00CD"` (5 bytes) +/// silently truncated to `"AB"` at the first NUL; the v20 bridge threads the +/// value's true byte length through instead. Also pins that a NUL-safe text bind +/// keeps SQLite's TEXT affinity/typeof (routed through `bind_text`, not +/// `bind_blob`). +#[test] +fn test_pdo_bind_param_str_preserves_embedded_nul() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (data TEXT)"); +$ins = $db->prepare("INSERT INTO t (data) VALUES (?)"); +$ins->bindValue(1, "AB\x00CD"); +$ins->execute(); +$row = $db->query("SELECT typeof(data) AS ty, data FROM t")->fetch(PDO::FETCH_ASSOC); +$back = $row["data"]; +echo $row["ty"] . ":" . strlen($back) . ":" . bin2hex($back); +"#, + ); + assert_eq!(out, "text:5:4142004344"); +} + +/// P0-A regression: `PDO::PARAM_LOB` now reaches `elephc_pdo_bind_blob` — declared +/// in the prelude and wired into `execute()`'s bind loop for the first time in +/// this slice, even though the bridge-side function has existed since v7. Raw +/// bytes `"\x00\xff\x01"` (an embedded NUL plus a non-UTF-8 0xFF byte) round-trip +/// unchanged through a BLOB column, proven via `bin2hex()`. +#[test] +fn test_pdo_bind_param_lob_preserves_binary() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (data BLOB)"); +$ins = $db->prepare("INSERT INTO t (data) VALUES (?)"); +$ins->bindValue(1, "\x00\xff\x01", PDO::PARAM_LOB); +$ins->execute(); +$row = $db->query("SELECT typeof(data) AS ty, data FROM t")->fetch(PDO::FETCH_ASSOC); +$back = $row["data"]; +echo $row["ty"] . ":" . strlen($back) . ":" . bin2hex($back); +"#, + ); + assert_eq!(out, "blob:3:00ff01"); +} + +/// P1-e: the SQLite (default) `quote()` branch still ignores `$type` entirely — +/// unlike the mysql/pgsql branches, which now special-case `PDO::PARAM_LOB` — a +/// regression guard mirroring php-src's own sqlite quoter, which never consults +/// the type argument either. The pgsql `'\xDEADBEEF...'` bytea-hex-literal branch +/// and the mysql `_binary'...'` branch (both added in this slice) need a live +/// server to exercise through a real connection's `elephc_pdo_driver_name()` +/// dispatch, so they are covered by `tests/codegen/pdo_mysql.rs`'s `#[ignore]` +/// live fixtures instead (`mysql_quote_param_lob_binary_prefix`); no live pgsql +/// fixture file exists in this slice's scope to add an equivalent there. +#[test] +fn test_pdo_quote_sqlite_ignores_param_lob_type() { + let out = compile_and_run( + r#"quote("O'Brien", PDO::PARAM_LOB); +"#, + ); + assert_eq!(out, "'O''Brien'"); +} + +/// P2-e: `setAttribute(PDO::ATTR_CASE, PDO::CASE_UPPER)` folds every FETCH_ASSOC +/// column-name key to uppercase (the original-case key is gone); the values are +/// untouched. `fetch()` returns `mixed`, so `array_keys()` (which needs a +/// statically-typed `array`) cannot be used here — `isset()` on both spellings +/// proves the fold happened instead. +#[test] +fn test_pdo_attr_case_upper_folds_keys() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_CASE, PDO::CASE_UPPER); +$db->exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada')"); +$row = $db->query("SELECT id, name FROM t")->fetch(PDO::FETCH_ASSOC); +$hasLower = isset($row["id"]) ? "yes" : "no"; +echo $hasLower . ":" . $row["ID"] . ":" . $row["NAME"]; +"#, + ); + assert_eq!(out, "no:1:Ada"); +} + +/// P3: `ATTR_CASE` folding is not FETCH_ASSOC-only — `setAttribute(PDO::ATTR_CASE, +/// PDO::CASE_UPPER)` also uppercases the dynamic PROPERTY name `FETCH_OBJ` +/// assigns on the fetched `stdClass` (via the same `columnName()` helper +/// `assignColumns()` uses), not just an array key. +#[test] +fn test_pdo_attr_case_upper_folds_fetch_obj_property() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_CASE, PDO::CASE_UPPER); +$db->exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada')"); +$row = $db->query("SELECT id, name FROM t")->fetch(PDO::FETCH_OBJ); +$hasLower = isset($row->id) ? "yes" : "no"; +echo $hasLower . ":" . $row->ID . ":" . $row->NAME; +"#, + ); + assert_eq!(out, "no:1:Ada"); +} + +/// P3: `ATTR_CASE` folding also applies to `FETCH_BOTH`'s STRING-keyed half — +/// the numerically-indexed half (`$row[0]`, `$row[1]`) is untouched, only the +/// column-name string key is folded, confirming the fold is keyed off +/// `columnName()` (shared by every fetch style) rather than something +/// FETCH_ASSOC-specific. +#[test] +fn test_pdo_attr_case_upper_folds_fetch_both_string_key() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_CASE, PDO::CASE_UPPER); +$db->exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada')"); +$row = $db->query("SELECT id, name FROM t")->fetch(PDO::FETCH_BOTH); +$hasLower = isset($row["id"]) ? "yes" : "no"; +$hasNumeric = isset($row[0]) && isset($row[1]) ? "yes" : "no"; +echo $hasLower . ":" . $hasNumeric . ":" . $row["ID"] . ":" . $row[0] . ":" . $row["NAME"] . ":" . $row[1]; +"#, + ); + assert_eq!(out, "no:yes:1:1:Ada:Ada"); +} + +/// P2-e: `setAttribute(PDO::ATTR_CASE, PDO::CASE_LOWER)` folds every FETCH_ASSOC +/// column-name key to lowercase, even when the SQL's own column aliases are +/// uppercase (sqlite3_column_name reports the alias exactly as written). +#[test] +fn test_pdo_attr_case_lower_folds_keys() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_CASE, PDO::CASE_LOWER); +$db->exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada')"); +$row = $db->query("SELECT id AS ID, name AS NAME FROM t")->fetch(PDO::FETCH_ASSOC); +$hasUpper = isset($row["ID"]) ? "yes" : "no"; +echo $hasUpper . ":" . $row["id"] . ":" . $row["name"]; +"#, + ); + assert_eq!(out, "no:1:Ada"); +} + +/// P2-e regression guard: with no `ATTR_CASE` ever set (the default +/// `PDO::CASE_NATURAL`), FETCH_ASSOC column-name keys are left exactly as the +/// driver reports them (no uppercase spelling appears). +#[test] +fn test_pdo_attr_case_natural_default() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO t VALUES (1, 'Ada')"); +$row = $db->query("SELECT id, name FROM t")->fetch(PDO::FETCH_ASSOC); +$hasUpper = isset($row["ID"]) ? "yes" : "no"; +echo $hasUpper . ":" . $row["id"] . ":" . $row["name"]; +"#, + ); + assert_eq!(out, "no:1:Ada"); +} + +/// P2-e: `setAttribute(PDO::ATTR_CASE, ...)` rejects a value outside +/// `{CASE_NATURAL, CASE_UPPER, CASE_LOWER}` with a `ValueError`, using the exact +/// message php-src's `pdo_dbh.c` uses ("Case folding mode must be one of the +/// PDO::CASE_* constants", confirmed against php-src). +#[test] +fn test_pdo_attr_case_rejects_invalid() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_CASE, 99); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:Case folding mode must be one of the PDO::CASE_* constants" + ); +} + +/// `Pdo\Sqlite::ATTR_OPEN_FLAGS`-style constructor option: `PDO::ATTR_CASE` set +/// through the constructor's `$options` array (rather than `setAttribute()`) +/// takes effect too, threaded the same way `ATTR_TIMEOUT`'s constructor-option +/// test already proves for a different attribute. +#[test] +fn test_pdo_attr_case_constructor_option() { + let out = compile_and_run( + r#" PDO::CASE_UPPER]); +$db->exec("CREATE TABLE t (id INTEGER)"); +$db->exec("INSERT INTO t VALUES (1)"); +$row = $db->query("SELECT id FROM t")->fetch(PDO::FETCH_ASSOC); +$hasLower = isset($row["id"]) ? "yes" : "no"; +echo $row["ID"] . ":" . $hasLower; +"#, + ); + assert_eq!(out, "1:no"); +} + +/// `getAttribute`/`setAttribute` round-trip both `ATTR_CASE` and +/// `ATTR_ORACLE_NULLS`; the default for each is the `*_NATURAL` value (0). +#[test] +fn test_pdo_get_set_attr_case_and_oracle_nulls() { + let out = compile_and_run( + r#"getAttribute(PDO::ATTR_CASE) . ":" . $db->getAttribute(PDO::ATTR_ORACLE_NULLS); +$db->setAttribute(PDO::ATTR_CASE, PDO::CASE_UPPER); +$db->setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_EMPTY_STRING); +echo ":" . $db->getAttribute(PDO::ATTR_CASE) . ":" . $db->getAttribute(PDO::ATTR_ORACLE_NULLS); +"#, + ); + assert_eq!(out, "0:0:1:1"); +} + +/// P2-e: `setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_EMPTY_STRING)` converts +/// an empty-string TEXT column value to `null` on fetch, mirroring php-src's +/// `fetch_value()` (`IS_STRING && Z_STRLEN_P(dest) == 0 && oracle_nulls == +/// PDO_NULL_EMPTY_STRING`). +#[test] +fn test_pdo_oracle_nulls_empty_string_to_null() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_EMPTY_STRING); +$db->exec("CREATE TABLE t (name TEXT)"); +$db->exec("INSERT INTO t VALUES ('')"); +$row = $db->query("SELECT name FROM t")->fetch(PDO::FETCH_ASSOC); +echo $row["name"] === null ? "null" : "not-null"; +"#, + ); + assert_eq!(out, "null"); +} + +/// P2-e sibling: `setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_TO_STRING)` +/// converts a `NULL` column value to `""` on fetch, mirroring php-src's +/// `fetch_value()` (`IS_NULL && oracle_nulls == PDO_NULL_TO_STRING`). +#[test] +fn test_pdo_oracle_nulls_null_to_empty_string() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_TO_STRING); +$db->exec("CREATE TABLE t (name TEXT)"); +$db->exec("INSERT INTO t (name) VALUES (NULL)"); +$row = $db->query("SELECT name FROM t")->fetch(PDO::FETCH_ASSOC); +echo $row["name"] === "" ? "empty" : ($row["name"] === null ? "still-null" : "other"); +"#, + ); + assert_eq!(out, "empty"); +} + +/// F-CORE-21: `PDO::exec("")` throws a `ValueError` before any driver call, exactly +/// like the `prepare("")` guard above it — php-src's `PHP_METHOD(PDO, exec)` raises +/// `zend_argument_must_not_be_empty_error(1)` from its own argument check, and its +/// `$statement` parameter name is what the message carries. Until this guard existed +/// the empty string reached the bridge, which cheerfully "executed" it. +#[test] +fn test_pdo_exec_empty_statement_throws() { + let out = compile_and_run( + r#"exec(""); + echo "no-throw"; +} catch (\ValueError $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "threw:PDO::exec(): Argument #1 ($statement) must not be empty" + ); +} + +/// F-CORE-22: `PDO::query("")` carries its OWN empty-statement guard. php-src's +/// `PHP_METHOD(PDO, query)` validates its argument itself, so the failure names +/// `PDO::query()`; elephc used to let the empty string fall through to the internal +/// `prepare()` delegation, which reported the error under the wrong method name +/// ("PDO::prepare(): ...") — a message a caller matching on it would never expect. +/// The parameter spelling inside the parentheses is deliberately NOT pinned here +/// (php-src derives it from arginfo; see the report), only the method name, the +/// "must not be empty" wording, and the absence of any `prepare` leakage. +#[test] +fn test_pdo_query_empty_statement_names_query_not_prepare() { + let out = compile_and_run( + r#"query(""); + echo "no-throw"; +} catch (\ValueError $e) { + $m = $e->getMessage(); + echo (str_starts_with($m, "PDO::query(): Argument #1 (") ? "query" : "wrong-method"); + echo ":" . (str_contains($m, "must not be empty") ? "empty" : "wrong-text"); + echo ":" . (str_contains($m, "prepare") ? "leaked-prepare" : "no-prepare"); +} +"#, + ); + assert_eq!(out, "query:empty:no-prepare"); +} + +/// F-CORE-03 (the sharpest edge of the finding): php-src's `pdo_get_long_param()` +/// checks the SHAPE of an attribute value before any range check and raises a +/// `TypeError` for a non-int/bool/integer-numeric-string. elephc used to blind-cast +/// with `(int) $value` — and `(int) "banana"` is `0`, i.e. `PDO::ERRMODE_SILENT`, +/// which `checkErrMode()` happily accepted. So a typo'd attribute value silently +/// switched the connection into SILENT and swallowed every subsequent error. This +/// pins all three halves: the TypeError, the error mode surviving unchanged, and — +/// the part that actually bit — that errors still THROW afterwards. +#[test] +fn test_pdo_set_attribute_errmode_rejects_non_int_value() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_ERRMODE, "banana"); + echo "no-throw"; +} catch (\TypeError $e) { + echo "threw:" . $e->getMessage(); +} +echo "|" . $db->getAttribute(PDO::ATTR_ERRMODE); +try { + $db->query("THIS IS NOT SQL"); + echo "|swallowed"; +} catch (PDOException $e) { + echo "|still-throws"; +} +"#, + ); + assert_eq!( + out, + "threw:Attribute value must be of type int for selected attribute, string given|2|still-throws" + ); +} + +/// F-CORE-03, bool half: php-src's `pdo_get_bool_param()` accepts only +/// `IS_TRUE`/`IS_FALSE`/`IS_LONG` (its `case IS_STRING:` deliberately falls through +/// to the TypeError), so an array — or any other shape — passed to a bool-typed +/// attribute like `ATTR_STRINGIFY_FETCHES` is a `TypeError`, not a `(bool)` cast. +#[test] +fn test_pdo_set_attribute_bool_attribute_rejects_non_bool_value() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_STRINGIFY_FETCHES, []); + echo "no-throw"; +} catch (\TypeError $e) { + echo "threw:" . $e->getMessage(); +} +echo "|" . ($db->getAttribute(PDO::ATTR_STRINGIFY_FETCHES) ? "1" : "0"); +"#, + ); + assert_eq!( + out, + "threw:Attribute value must be of type bool for selected attribute, array given|0" + ); +} + +/// F-CORE-03 regression guard: the new shape check must not narrow what php-src +/// ACCEPTS. `pdo_get_long_param()` takes an int, a bool, and a string that +/// `is_numeric_str_function()` reports as `IS_LONG` — so a genuinely-int error mode +/// and the numeric string `"2"` both still set it, and an int still satisfies a +/// bool-typed attribute (php-src's `IS_LONG` case for `pdo_get_bool_param()`). +#[test] +fn test_pdo_set_attribute_accepts_int_and_numeric_string() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); +echo $db->getAttribute(PDO::ATTR_ERRMODE); +$db->setAttribute(PDO::ATTR_ERRMODE, "2"); +echo ":" . $db->getAttribute(PDO::ATTR_ERRMODE); +$db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, 1); +echo ":" . ($db->getAttribute(PDO::ATTR_STRINGIFY_FETCHES) ? "1" : "0"); +"#, + ); + assert_eq!(out, "0:2:1"); +} + +/// F-CORE-03, constructor half: php-src runs the constructor's `$options` array +/// through the very same `pdo_get_long_param()`/`pdo_get_bool_param()` helpers as +/// `setAttribute()`, and elephc's `$options` loop had the identical blind-cast hole — +/// so `new PDO($dsn, null, null, [PDO::ATTR_ERRMODE => "banana"])` used to open the +/// connection in ERRMODE_SILENT. The TypeError must be raised before the connection +/// is opened, so the object is never handed back at all. +#[test] +fn test_pdo_constructor_options_reject_bad_attribute_shape() { + let out = compile_and_run( + r#" "banana"]); + echo "no-throw"; +} catch (\TypeError $e) { + echo "int:" . $e->getMessage(); +} +try { + $db2 = new PDO("sqlite::memory:", null, null, [PDO::ATTR_STRINGIFY_FETCHES => "yes"]); + echo "|no-throw"; +} catch (\TypeError $e) { + echo "|bool:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "int:Attribute value must be of type int for selected attribute, string given|bool:Attribute value must be of type bool for selected attribute, string given" + ); +} + +/// F-SQLT-01: php-src's `pdo_sqlite` registers its driver constants against the BASE +/// `PDO` class (`pdo_dbh_ce`) in parallel with the class-scoped `Pdo\Sqlite::*` +/// spellings added in 8.1 — `PDO::SQLITE_ATTR_OPEN_FLAGS` and friends are the +/// pre-8.1 API surface a great deal of real-world code still uses, and elephc had +/// none of them. The two spellings are aliases: same value, both live. +#[test] +fn test_pdo_legacy_sqlite_constants_alias_pdo_sqlite() { + let out = compile_and_run( + r#"prepare("SELECT ?"); +try { + $stmt->bindValue(0, "x"); + echo "no-throw"; +} catch (\ValueError $e) { + echo "zero:" . $e->getMessage(); +} +try { + $stmt->bindValue(-1, "x"); + echo "|no-throw"; +} catch (\ValueError $e) { + echo "|neg"; +} +try { + $stmt->bindValue("", "x"); + echo "|no-throw"; +} catch (\ValueError $e) { + echo "|empty:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "zero:PDOStatement::bindValue(): Argument #1 ($param) must be greater than or equal to 1|neg|empty:PDOStatement::bindValue(): Argument #1 ($param) must not be empty" + ); +} + +/// F-STMT-05, sibling methods: php-src validates `bindParam()`'s and `bindColumn()`'s +/// own Argument #1 with the same two checks. `bindParam()` must raise under its OWN +/// name (not the `bindValue()` it delegates to), and `bindColumn()`'s parameter +/// validation runs ahead of registration, so malformed keys raise the PHP-matching +/// ValueError while a valid named output column is accepted. +#[test] +fn test_pdo_bind_param_and_bind_column_validate_argument_one() { + let out = compile_and_run( + r#"prepare("SELECT ?"); + $v = "x"; + try { + $stmt->bindParam(0, $v); + echo "no-throw"; + } catch (\ValueError $e) { + echo "param:" . $e->getMessage(); + } + try { + $stmt->bindColumn(0, $col); + echo "|no-throw"; + } catch (\ValueError $e) { + echo "|col-zero"; + } + try { + $stmt->bindColumn("", $col); + echo "|no-throw"; + } catch (\ValueError $e) { + echo "|col-empty"; + } + try { + $stmt->bindColumn("c", $col); + echo "|col-supported"; + } catch (PDOException $e) { + echo "|unexpected"; + } +} +run(); +"#, + ); + assert_eq!( + out, + "param:PDOStatement::bindParam(): Argument #1 ($param) must be greater than or equal to 1|col-zero|col-empty|col-supported" + ); +} + +/// F-STMT-06: a named placeholder the prepared SQL never declares resolves to bind +/// index `0` (`sqlite3_bind_parameter_index()`'s "unknown" answer), and neither +/// `execute()` replay loop used to check it — the value simply vanished while +/// `execute()` reported success. php-src raises HY093 "Invalid parameter number: +/// parameter was not defined" instead. Both binding paths are covered: the recorded +/// `bindValue()` replay, and the `execute($params)` array with an unknown key. +/// `errorInfo[0]` is what frameworks parse, so the SQLSTATE is asserted there. +#[test] +fn test_pdo_execute_unknown_named_placeholder_raises_hy093() { + let out = compile_and_run( + r#"prepare("SELECT ?"); +$a->bindValue(":nope", 1); +try { + $a->execute(); + echo "no-throw"; +} catch (PDOException $e) { + echo $e->errorInfo[0] . ":" . $e->getMessage(); +} +$b = $db->prepare("SELECT ?"); +try { + $b->execute([":nope" => 1]); + echo "|no-throw"; +} catch (PDOException $e) { + echo "|" . $e->errorInfo[0]; +} +"#, + ); + assert_eq!( + out, + "HY093:SQLSTATE[HY093]: Invalid parameter number: parameter was not defined|HY093" + ); +} + +/// F-PARSE-06: every `elephc_pdo_bind_*` already returned `0` for an out-of-range +/// slot (SQLite's `SQLITE_RANGE`), but `execute()` checked no return code at all — so +/// `bindValue(5, ...)` on a 2-placeholder statement was a silent no-op and the +/// statement ran anyway with whatever the other slots held. php-src raises HY093 +/// "Invalid parameter number". The row count pins the other half: the statement must +/// NOT have been executed. Under ERRMODE_SILENT the same failure returns `false` +/// rather than reporting a phantom success. +#[test] +fn test_pdo_execute_out_of_range_slot_raises_hy093() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (a INTEGER, b INTEGER)"); +$stmt = $db->prepare("INSERT INTO t (a, b) VALUES (?, ?)"); +$stmt->bindValue(1, 1); +$stmt->bindValue(2, 2); +$stmt->bindValue(5, "x"); +try { + $stmt->execute(); + echo "no-throw"; +} catch (PDOException $e) { + echo $e->errorInfo[0] . ":" . $e->getMessage(); +} +echo "|" . $db->query("SELECT COUNT(*) FROM t")->fetchColumn(); + +$silent = new PDO("sqlite::memory:", null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT]); +$silent->exec("CREATE TABLE t (a INTEGER, b INTEGER)"); +$st = $silent->prepare("INSERT INTO t (a, b) VALUES (?, ?)"); +$st->bindValue(5, "x"); +echo "|" . (($st->execute() === false) ? "false" : "true"); +"#, + ); + assert_eq!(out, "HY093:SQLSTATE[HY093]: Invalid parameter number|0|false"); +} + +/// F-STMT-08: php-src ALWAYS reduces a bound type to its base type before +/// dispatching on it — `PDO_PARAM_TYPE(x)` is `((x) & ~PDO_PARAM_FLAGS)` with +/// `PDO_PARAM_FLAGS = 0xFFFF0000`, the high half where `PARAM_INPUT_OUTPUT` lives. +/// Dispatching on the RAW value made `PDO::PARAM_INT|PDO::PARAM_INPUT_OUTPUT` match +/// no branch and fall through to the generic TEXT one, binding an int as a string. +/// SQLite's `typeof()` reports the bound value's real storage class, so it catches +/// exactly that: an unmasked dispatch answers `text`, not `integer`. (A column with +/// INTEGER affinity would have silently coerced the string back and hidden the bug, +/// hence the bare `SELECT typeof(?)`.) +#[test] +fn test_pdo_bind_param_int_with_input_output_flag_binds_int() { + let out = compile_and_run( + r#"prepare("SELECT typeof(?) AS t, ? AS v"); +$stmt->bindValue(1, 42, PDO::PARAM_INT | PDO::PARAM_INPUT_OUTPUT); +$stmt->bindValue(2, 42, PDO::PARAM_INT | PDO::PARAM_INPUT_OUTPUT); +$stmt->execute(); +$row = $stmt->fetch(PDO::FETCH_ASSOC); +echo $row["t"] . ":" . $row["v"] . ":" . (is_int($row["v"]) ? "int" : "notint"); +"#, + ); + assert_eq!(out, "integer:42:int"); +} + +/// F-STMT-07: `PDO::PARAM_BOOL` now takes the driver's own boolean bind +/// (php-src's `PDO_PARAM_BOOL` case) instead of being folded into `PARAM_INT` — +/// which is what lets PostgreSQL send a real `'t'`/`'f'` for a BOOL column. SQLite +/// binds 0/1 either way, so the discriminator that proves the bool branch is taken +/// is php-src's `zval_is_true()` reduction: a bound `5` must arrive as `1`, whereas +/// the PARAM_INT branch would have bound `5` verbatim. +#[test] +fn test_pdo_bind_value_param_bool_reduces_to_truthiness() { + let out = compile_and_run( + r#"prepare("SELECT typeof(?) AS t, ? AS a, ? AS b, ? AS c"); +$stmt->bindValue(1, true, PDO::PARAM_BOOL); +$stmt->bindValue(2, true, PDO::PARAM_BOOL); +$stmt->bindValue(3, false, PDO::PARAM_BOOL); +$stmt->bindValue(4, 5, PDO::PARAM_BOOL); +$stmt->execute(); +$row = $stmt->fetch(PDO::FETCH_ASSOC); +echo $row["t"] . ":" . $row["a"] . ":" . $row["b"] . ":" . $row["c"]; +"#, + ); + assert_eq!(out, "integer:1:0:1"); +} + +/// F-QUAL-01: `columnValue()` — the single dispatch point every fetch path goes +/// through — used to copy a TEXT/BLOB value out of the bridge ONE BYTE AT A TIME +/// (`chr(elephc_pdo_column_data_byte(...))` in a loop: N FFI calls, each locking and +/// unlocking the bridge's statement table, plus N string concatenations, so an N-byte +/// column cost O(N) FFI calls and built its string in O(N²)). It now copies the value +/// in ONE call through `ptr_read_string(elephc_pdo_column_data_ptr(...), $_len)`. +/// +/// The regression that rewrite risks is byte-exactness: the byte loop existed +/// precisely because embedded NUL bytes must survive (php-src's PDO hands back a +/// length-counted `zend_string`, never a C string — `pdo_stmt.c`'s `fetch_value()` +/// uses the driver's reported byte length). `column_data_ptr`/`column_data_len` are +/// the length-counted pair and `__rt_ptr_read_string` copies an EXACT byte count with +/// no NUL-termination semantics, so this must hold at a size where the old loop would +/// have been ruinous: a ~5 KB value with two embedded NULs, compared byte-for-byte. +#[test] +fn test_pdo_fetch_multi_kb_text_with_embedded_nuls_is_byte_exact() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (data TEXT)"); +$big = str_repeat("A", 2000) . "\x00" . str_repeat("B", 2000) . "\x00" . str_repeat("C", 1000); +$ins = $db->prepare("INSERT INTO t (data) VALUES (?)"); +$ins->bindValue(1, $big); +$ins->execute(); +$back = (string) $db->query("SELECT data FROM t")->fetchColumn(); +echo strlen($big) . ":" . strlen($back) + . ":" . ((bin2hex($back) === bin2hex($big)) ? "same" : "diff") + . ":" . ord($back[2000]) . ":" . ord($back[4001]) . ":" . ord($back[4002]); +"#, + ); + assert_eq!(out, "5002:5002:same:0:0:67"); +} + +/// F-QUAL-01, the NULL-pointer edge the rewrite HAD to guard. The bridge's +/// `store_bytes` reports an EMPTY buffer as a NULL data pointer, and `ptr_read_string` +/// lowers to `__rt_ptr_check_nonnull` BEFORE it ever looks at the length — so an +/// unguarded `ptr_read_string(column_data_ptr(...), 0)` on an empty TEXT column or a +/// zero-length BLOB would hard-ABORT the process, where the old byte loop simply ran +/// zero iterations and yielded `""`. A regression here is a crash, not a wrong value, +/// which is why it gets its own fixture. php-src fetches both as an empty string +/// (`ATTR_ORACLE_NULLS` is `NULL_NATURAL` by default, so no ""→null conversion). +#[test] +fn test_pdo_fetch_empty_text_and_zero_length_blob() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (txt TEXT, bin BLOB)"); +$db->exec("INSERT INTO t (txt, bin) VALUES ('', x'')"); +$row = $db->query("SELECT txt, bin FROM t")->fetch(PDO::FETCH_ASSOC); +echo "[" . $row["txt"] . "]:" . strlen($row["txt"]) + . ":[" . $row["bin"] . "]:" . strlen($row["bin"]); +"#, + ); + assert_eq!(out, "[]:0:[]:0"); +} + +/// F-QUAL-01, `blobStream()` half: bounded BLOB reads still copy each returned slice +/// through `elephc_pdo_blob_data_ptr()` in one `ptr_read_string`, never one FFI call +/// per byte. Two risks are pinned at BLOB scale: a ~3 KB body with an embedded NUL +/// must arrive byte-identical, and a ZERO-LENGTH blob (whose buffer the bridge reports +/// as a NULL pointer) must yield an empty stream rather than aborting on +/// `__rt_ptr_check_nonnull`. The zero-length read is a SUCCESS (0 bytes), which is a +/// different answer from the `false` a missing row returns — that distinction is +/// covered by `test_pdo_sqlite_open_blob`. +#[test] +fn test_pdo_sqlite_open_blob_multi_kb_and_zero_length() { + let out = compile_and_run( + r#"exec("CREATE TABLE imgs (id INTEGER PRIMARY KEY, body BLOB)"); +$big = str_repeat("x", 1500) . "\x00" . str_repeat("y", 1500); +$ins = $db->prepare("INSERT INTO imgs (id, body) VALUES (1, ?)"); +$ins->bindValue(1, $big, PDO::PARAM_LOB); +$ins->execute(); +$db->exec("INSERT INTO imgs (id, body) VALUES (2, x'')"); + +$s = $db->openBlob("imgs", "body", 1); +$content = (string) stream_get_contents($s); +$e = $db->openBlob("imgs", "body", 2); +$empty = (string) stream_get_contents($e); +echo strlen($content) . ":" . ((bin2hex($content) === bin2hex($big)) ? "same" : "diff") + . ":" . ord($content[1500]) . "|" . strlen($empty); +"#, + ); + assert_eq!(out, "3001:same:0|0"); +} + +/// F-SQLT-02: `Pdo\Sqlite::ATTR_EXTENDED_RESULT_CODES` (1002) used to be a +/// complete no-op. php-src's `pdo_sqlite_set_attribute` +/// calls `sqlite3_extended_result_codes(H->db, lval)`, which widens what +/// `sqlite3_errcode()` reports — and PDO surfaces that value verbatim as +/// `errorInfo[1]` — from the coarse primary code (`SQLITE_CONSTRAINT` = 19, "some +/// constraint broke") to the extended code naming WHICH constraint +/// (`SQLITE_CONSTRAINT_UNIQUE` = 2067). It is a live toggle, so turning it back off +/// restores the primary code. +/// +/// `errorInfo[0]` deliberately degrades to "HY000" while the attribute is on, and that +/// is php-src parity, not a bug: `pdo_sqlite_error()` switches on the SAME unmasked +/// `sqlite3_errcode()` value, so its `case SQLITE_CONSTRAINT: → "23000"` no longer +/// matches once the code is 2067 and it falls through to its `default: → "HY000"`. +/// Masking back to the primary code would have DIVERGED from php-src, so the SQLSTATE +/// is pinned here alongside the native code to keep that trade-off honest. +#[test] +fn test_pdo_sqlite_extended_result_codes_widen_error_info() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); +$db->exec("CREATE TABLE t (id INTEGER PRIMARY KEY, u TEXT UNIQUE)"); +$db->exec("INSERT INTO t (id, u) VALUES (1, 'a')"); + +$db->exec("INSERT INTO t (id, u) VALUES (2, 'a')"); +$plain = $db->errorInfo(); + +$db->setAttribute(PDO::SQLITE_ATTR_EXTENDED_RESULT_CODES, true); +$db->exec("INSERT INTO t (id, u) VALUES (3, 'a')"); +$ext = $db->errorInfo(); + +$db->setAttribute(PDO::SQLITE_ATTR_EXTENDED_RESULT_CODES, false); +$db->exec("INSERT INTO t (id, u) VALUES (4, 'a')"); +$off = $db->errorInfo(); + +echo $plain[0] . "/" . $plain[1] . "|" . $ext[0] . "/" . $ext[1] . "|" . $off[0] . "/" . $off[1]; +"#, + ); + assert_eq!(out, "23000/19|HY000/2067|23000/19"); +} + +/// F-SQLT-02, shape check: php-src reads this attribute through `pdo_get_bool_param()` +/// (`zend_parse_arg_bool`), so a non-bool, non-int value is a TypeError, not a silent +/// truthiness cast. The new `setAttribute()` branch therefore routes 1002 through the +/// same `attrBoolValue()` helper `ATTR_STRINGIFY_FETCHES` uses, and +/// the message must match theirs byte for byte. +#[test] +fn test_pdo_sqlite_extended_result_codes_rejects_non_bool() { + let out = compile_and_run( + r#"setAttribute(PDO::SQLITE_ATTR_EXTENDED_RESULT_CODES, "banana"); + echo "no-throw"; +} catch (\TypeError $e) { + echo $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "Attribute value must be of type bool for selected attribute, string given" + ); +} + +/// F-SQLT-02: `ATTR_EXTENDED_RESULT_CODES` is write-only in real PHP. The setter +/// succeeds, while the getter follows IM001 instead of echoing a retained value. +#[test] +fn test_pdo_sqlite_extended_result_codes_get_attribute_is_unsupported() { + let out = compile_and_run( + r#" PDO::ERRMODE_SILENT]); +$ok = $db->setAttribute(PDO::SQLITE_ATTR_EXTENDED_RESULT_CODES, true); +$after = $db->getAttribute(PDO::SQLITE_ATTR_EXTENDED_RESULT_CODES); +echo ($ok ? "set" : "failed") . ":" . (($after === false) ? "unsupported" : "echoed"); +"#, + ); + assert_eq!(out, "set:unsupported"); +} + +/// F-CORE-15: php-src marks `class PDO` `/** @not-serializable */` in +/// `ext/pdo/pdo.stub.php`, which installs `zend_class_serialize_deny` as the class's +/// serialize handler, so `serialize($pdo)` throws +/// `Exception: Serialization of 'PDO' is not allowed` — a plain `Exception`, because +/// `zend_class_serialize_deny` passes a NULL class entry to `zend_throw_exception_ex` +/// (so NOT a `PDOException`, and NOT a `ValueError`). +/// +/// elephc has no per-class engine flag for that, and its `serialize()` used to simply +/// WALK THE PROPERTIES: the blob it emitted carried `PDO::$conn`, the raw integer +/// bridge handle, and `unserialize()` handed back a zombie PDO whose handle indexes +/// nothing. The guard is therefore implemented in the prelude as a `__serialize()` +/// override that throws — elephc's `__rt_serialize_object` consults the per-class +/// `_class_serialize_ptrs` table before walking properties +/// (`src/codegen_support/runtime/data/user.rs:289`), so the magic method fires and the +/// throw unwinds out of the runtime's serialize frame. +#[test] +fn test_pdo_serialize_is_denied() { + let out = compile_and_run( + r#"getMessage(); + echo $msg; +} +"#, + ); + assert_eq!(out, "Serialization of 'PDO' is not allowed"); +} + +/// F-CORE-15: `PDOStatement` carries the same `/** @not-serializable */` annotation in +/// `ext/pdo/pdo.stub.php`, for the same reason (its blob would leak the bridge's +/// statement handle), and reports its own class name in the message. +#[test] +fn test_pdo_statement_serialize_is_denied() { + let out = compile_and_run( + r#"query("SELECT 1"); +try { + $blob = serialize($stmt); + echo "no-throw:" . $blob; +} catch (\Exception $e) { + $msg = $e->getMessage(); + echo $msg; +} +"#, + ); + assert_eq!(out, "Serialization of 'PDOStatement' is not allowed"); +} + +/// F-CORE-15: the deny guard names the RUNTIME class, not the class that declares +/// `__serialize()` — php-src's `zend_class_serialize_deny` formats `ZSTR_VAL(ce->name)` +/// of the object being serialized, and the driver subclasses inherit the deny handler +/// from `PDO`. The prelude's `get_class($this)` reproduces that, so a `Pdo\Sqlite` +/// reports its own name. (This also proves the guard is inherited at all: without it, +/// the subclass would fall through to the property walk that `PDO` no longer takes.) +#[test] +fn test_pdo_serialize_denied_reports_subclass_name() { + let out = compile_and_run( + r#"getMessage(); + echo $msg; +} +"#, + ); + assert_eq!(out, "Serialization of 'Pdo\\Sqlite' is not allowed"); +} + +/// F-CORE-01: php-src's `create_driver_specific_pdo_object` (`pdo_dbh.c:222-299`) +/// compares the DSN's driver against the driver-specific subclass being constructed and +/// refuses a mismatch, BEFORE any connection is attempted — so this needs no live +/// server. `Pdo\Mysql` had no constructor at all here, so `new Pdo\Mysql("sqlite:…")` +/// used to open a SQLite database behind a `Pdo\Mysql` object: an object whose class +/// lies about what it is, and whose MySQL-only methods then fail deep in the bridge. +/// The message is php-src's, byte for byte. +#[test] +fn test_pdo_mysql_subclass_ctor_rejects_sqlite_dsn() { + let out = compile_and_run( + r#"getMessage(); + echo $msg; +} +"#, + ); + assert_eq!( + out, + "Pdo\\Mysql::__construct() cannot be used for connecting to the \"sqlite\" driver, \ + either call Pdo\\Sqlite::__construct() or PDO::__construct() instead" + ); +} + +/// F-CORE-01, the mirror case: a `pgsql:` DSN handed to `Pdo\Sqlite`. The guard runs +/// ahead of `parent::__construct()`, so no PostgreSQL server is contacted (and none is +/// running in CI) — the throw is purely a DSN-prefix comparison. +#[test] +fn test_pdo_sqlite_subclass_ctor_rejects_pgsql_dsn() { + let out = compile_and_run( + r#"getMessage(); + echo $msg; +} +"#, + ); + assert_eq!( + out, + "Pdo\\Sqlite::__construct() cannot be used for connecting to the \"pgsql\" driver, \ + either call Pdo\\Pgsql::__construct() or PDO::__construct() instead" + ); +} + +/// F-CORE-01, the negative control the guard must not break: the CORRECT pairing still +/// connects and queries. Without this, a guard that rejected everything would pass both +/// tests above. +#[test] +fn test_pdo_sqlite_subclass_ctor_accepts_sqlite_dsn() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (n INTEGER)"); +$db->exec("INSERT INTO t VALUES (9)"); +echo $db->query("SELECT n FROM t")->fetchColumn() . "|" . $db->getAttribute(PDO::ATTR_DRIVER_NAME); +"#, + ); + assert_eq!(out, "9|sqlite"); +} + +/// F-CORE-04, **CORRECTED**. An earlier pass implemented the finalization spec's version +/// of this finding, and the spec was WRONG: `PDO_FINALIZATION_SPEC_V3.md:168` specifies a +/// LOUD rejection (exception under `ERRMODE_EXCEPTION`), and this test used to pin it. +/// +/// Real PHP's `PDO::setAttribute()` on an unknown attribute returns **false SILENTLY**. It +/// raises NOTHING — no exception, no error state — not even under `ERRMODE_EXCEPTION`. +/// VERIFIED against a real PHP 8.5.6 CLI: `$pdo->setAttribute(9999, 1)` on an +/// `ERRMODE_EXCEPTION` handle returns `bool(false)` and `$pdo->errorCode()` still reads +/// `"00000"`. +/// +/// WHY, in php-src's own terms: `pdo_dbh_attribute_set()` only reaches +/// `pdo_raise_impl_error(…, "IM001", "driver does not support setting attributes")` on the +/// `!dbh->methods->set_attribute` arm — a driver with NO `set_attribute` hook AT ALL. All +/// three drivers here (pdo_sqlite, pdo_mysql, pdo_pgsql) HAVE one, and each simply +/// `return 0`s for an attribute it does not recognize WITHOUT setting an error, so the +/// `PDO_HANDLE_DBH_ERR()` that follows finds SQLSTATE `00000` and raises nothing. The +/// IM001 arm is unreachable for every driver this bridge implements. +/// +/// So all three are asserted together: the return is `false`, the error mode is +/// irrelevant (no throw under EXCEPTION), and `errorCode()` is untouched. What SURVIVES +/// from the original finding is that NOTHING IS STORED — store-and-return-true was wrong +/// under any reading. `getAttribute()`'s IM001 is genuinely asymmetric and still raises +/// (see below); that asymmetry looks like a php-src bug, but it is the behavior. +#[test] +fn test_pdo_set_attribute_unknown_returns_false_silently_under_exception() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +try { + $ok = $db->setAttribute(99999, 1); + echo (($ok === false) ? "false" : "true") . "|" . $db->errorCode(); +} catch (\PDOException $e) { + echo "threw:" . $e->getMessage(); +} +"#, + ); + assert_eq!(out, "false|00000"); +} + +/// F-CORE-04: under `ERRMODE_SILENT` the rejected attribute is likewise quiet and returns +/// `false` — which, after the correction above, is now the SAME behavior as under +/// EXCEPTION rather than the quiet half of an errMode-aware raise. It must also store +/// NOTHING — a rejected attribute that still landed in the bag would read back out of +/// `getAttribute()` and defeat the whole finding — which is why the read-back is asserted +/// here rather than trusted. +#[test] +fn test_pdo_set_attribute_unknown_returns_false_under_silent() { + let out = compile_and_run( + r#" PDO::ERRMODE_SILENT]); +$ok = $db->setAttribute(99999, 1); +$back = $db->getAttribute(99999); +echo (($ok === false) ? "false" : "true") . "|" . (($back === false) ? "false" : "stored"); +"#, + ); + assert_eq!(out, "false|false"); +} + +/// F-CORE-05: `getAttribute()` on a number that is not a PDO attribute raises IM001 +/// "driver does not support that attribute" and returns php-src's literal `RETURN_FALSE` +/// (not NULL). Unlike `setAttribute`'s IM001 this one IS exactly what real PHP does: +/// `pdo_sqlite_get_attribute` returning 0 lands on an explicit `pdo_raise_impl_error` +/// in `pdo_dbh.c`'s `case 0:` arm. elephc used to return NULL, indistinguishable from a +/// known attribute nobody had set. +#[test] +fn test_pdo_get_attribute_unknown_throws_im001_under_exception() { + let out = compile_and_run( + r#"getAttribute(99999); + echo "no-throw"; +} catch (\PDOException $e) { + $msg = $e->getMessage(); + $info = $e->errorInfo; + echo $msg . "|" . $info[0]; +} +"#, + ); + assert_eq!( + out, + "SQLSTATE[IM001]: Driver does not support this function: driver does not support that attribute|IM001" + ); +} + +/// F-CORE-05: under `ERRMODE_SILENT`, `getAttribute()` on an unsupported attribute is +/// quiet and yields `false`, whether the number names a generic PDO constant or is +/// completely unknown. Driver support, not membership in a numeric range, is decisive. +#[test] +fn test_pdo_get_attribute_unknown_returns_false_under_silent() { + let out = compile_and_run( + r#" PDO::ERRMODE_SILENT]); +$unknown = $db->getAttribute(99999); +$unsupported = $db->getAttribute(PDO::ATTR_PREFETCH); +echo (($unknown === false) ? "false" : "other") . "|" . (($unsupported === false) ? "false" : "other"); +"#, + ); + assert_eq!(out, "false|false"); +} + +/// F-CORE-04/F-CORE-05: constants that the active SQLite driver does not implement are +/// rejected instead of being stored in a generic echo bag. This matches the SQLite +/// driver hook in php-src for cursor, emulated-prepare and default-string attributes. +#[test] +fn test_pdo_sqlite_rejects_unsupported_known_attributes() { + let out = compile_and_run( + r#" PDO::ERRMODE_SILENT]); +$cursor = $db->setAttribute(PDO::ATTR_CURSOR, PDO::CURSOR_SCROLL); +$emulate = $db->setAttribute(PDO::ATTR_EMULATE_PREPARES, true); +$strParam = $db->setAttribute(PDO::ATTR_DEFAULT_STR_PARAM, PDO::PARAM_STR); +echo (($cursor === false) ? "rejected" : "stored") . "|" + . (($emulate === false) ? "rejected" : "stored") . "|" + . (($strParam === false) ? "rejected" : "stored"); +"#, + ); + assert_eq!(out, "rejected|rejected|rejected"); +} + +/// SQLite rejects a non-forward prepare-time cursor option by returning false, +/// even under exception mode, matching `sqlite_handle_preparer()`. +#[test] +fn test_pdo_sqlite_rejects_scroll_cursor_prepare_option() { + let out = compile_and_run( + r#" PDO::ERRMODE_EXCEPTION]); +$stmt = $db->prepare("SELECT 1", [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]); +echo ($stmt === false) ? "rejected" : "accepted"; +"#, + ); + assert_eq!(out, "rejected"); +} + +/// A driver-specific constant from another driver is rejected by SQLite rather than +/// silently retained. The overlapping numeric ranges are interpreted only by the active +/// driver's hook, exactly as in php-src. +#[test] +fn test_pdo_sqlite_rejects_foreign_driver_attribute() { + let out = compile_and_run( + r#"setAttribute(Pdo\Mysql::ATTR_LOCAL_INFILE_DIRECTORY, "/var/lib/import"); +echo ($stored ? "stored" : "rejected"); +"#, + ); + assert_eq!(out, "rejected"); +} + +/// F-CORE-11: php-src's `dsn_from_uri` (`pdo_dbh.c:208-220`, called from the constructor +/// at `pdo_dbh.c:346-358`) treats a `uri:` DSN as INDIRECT — it opens the referenced +/// stream and takes the real DSN from its FIRST LINE, so a credentials-bearing DSN can +/// live outside the source tree. elephc had no `uri:` handling at all, so such a DSN +/// reached the bridge verbatim and failed as an unknown driver. A file whose first line +/// is `sqlite::memory:` must therefore produce a working in-memory SQLite connection, +/// indistinguishable from `new PDO("sqlite::memory:")`. +/// +/// The `file://` spelling is PHP's own documented one for this feature. elephc's +/// `fopen()` has no `file://` stream wrapper, so the prelude strips the scheme and opens +/// the remainder as a plain path — a divergence in mechanism that is invisible here, +/// which is exactly the point of pinning the documented spelling rather than the bare +/// path. +#[test] +fn test_pdo_uri_dsn_resolves_first_line_of_file() { + let out = compile_and_run( + r#"exec("CREATE TABLE t (n INTEGER)"); +$db->exec("INSERT INTO t VALUES (13)"); +echo $db->query("SELECT n FROM t")->fetchColumn() . "|" . $db->getAttribute(PDO::ATTR_DRIVER_NAME); +unlink($path); +"#, + ); + assert_eq!(out, "13|sqlite"); +} + +/// F-CORE-11: a `uri:` DSN whose stream cannot be opened is an argument error, not a +/// connect failure — php-src's `dsn_from_uri` returns NULL and the constructor raises +/// `zend_argument_error(pdo_exception_ce, 1, "must be a valid data source URI")`. Note +/// the exception CLASS: `zend_argument_error`'s first parameter is the class entry, so +/// this is an argument-error MESSAGE SHAPE thrown as a **PDOException**, not a +/// `ValueError` (verified against a real PHP 8.5.6 CLI — reading only the +/// `zend_argument_*` call name gives the wrong class here). +#[test] +fn test_pdo_uri_dsn_unreadable_throws_argument_error_shape() { + let out = compile_and_run( + r#"getMessage(); + echo $msg; +} +"#, + ); + assert_eq!( + out, + "PDO::__construct(): Argument #1 ($dsn) must be a valid data source URI" + ); +} + +/// F-CORE-13: the constructor and the `PDO::connect()` factory now report an unknown +/// driver with ONE message, php-src's bare `"could not find driver"`. The constructor +/// used to let the bridge fail the open and surfaced ITS text — `"could not find driver +/// (only sqlite:, pgsql:, and mysql: DSNs are supported)"` — while `connect()` already +/// threw php-src's, so one failure had two messages inside one class. php-src +/// deliberately keeps the DSN (which may carry a password) out of that text; the helpful +/// driver list now lives in a code comment and in `docs/php/pdo.md`, not in a message +/// callers may match on. +/// +/// Equality of the two messages is asserted directly, so this cannot drift back apart +/// silently. +#[test] +fn test_pdo_unknown_driver_message_identical_for_ctor_and_connect() { + let out = compile_and_run( + r#"getMessage(); +} +$connectMsg = ""; +try { + $db2 = \PDO::connect("oracle:x"); +} catch (\PDOException $e) { + $connectMsg = $e->getMessage(); +} +echo (($ctorMsg === $connectMsg) ? "same" : "diff") . "|" . $ctorMsg; +"#, + ); + assert_eq!(out, "same|could not find driver"); +} + +/// F-CORE-13: a DSN with NO COLON AT ALL is a different failure with a different +/// message — php-src validates the colon first (`pdo_dbh.c:346-372`) and raises the +/// argument-error shape `"PDO::__construct(): Argument #1 ($dsn) must be a valid data +/// source name"`, reaching `"could not find driver"` only for a colon-prefixed DSN whose +/// driver is unregistered. elephc used to give a colonless DSN neither message. +/// +/// The exception is a **PDOException**, NOT a `ValueError` — `zend_argument_error`'s +/// first argument is the exception class entry, and php-src passes `pdo_exception_ce` +/// (verified on a real PHP 8.5.6 CLI: `get_class($e)` on `new PDO("nocolon")` is +/// "PDOException"). This test would not compile-and-pass if the throw were a ValueError, +/// which is the point of catching the narrow class here. +#[test] +fn test_pdo_colonless_dsn_throws_argument_error_shape() { + let out = compile_and_run( + r#"getMessage(); + echo $msg; +} +"#, + ); + assert_eq!( + out, + "PDO::__construct(): Argument #1 ($dsn) must be a valid data source name" + ); +} + +/// Resolves a colonless PDO DSN from the runtime `pdo.dsn.*` configuration and +/// uses the resolved driver for both construction and driver-name reporting. +#[test] +fn test_pdo_ini_dsn_alias_opens_resolved_sqlite_driver() { + let out = compile_and_run_with_php_ini( + r#"getAttribute(PDO::ATTR_DRIVER_NAME) . "|"; +$db->exec("CREATE TABLE aliases (value TEXT)"); +$db->exec("INSERT INTO aliases VALUES ('ok')"); +echo $db->query("SELECT value FROM aliases")->fetchColumn(); +"#, + "pdo.dsn.memory = \"sqlite::memory:\"\n", + ); + assert_eq!(out, "sqlite|ok"); +} + +/// Reports php-src's distinct error when an INI alias exists but its configured +/// value is itself not a colon-bearing data source name. +#[test] +fn test_pdo_ini_dsn_alias_rejects_invalid_configured_value() { + let out = compile_and_run_with_php_ini( + r#"getMessage(); +} +"#, + "pdo.dsn.broken = \"not-a-dsn\"\n", + ); + assert_eq!(out, "invalid data source name (via INI: pdo.dsn.broken)"); +} + +/// Applies PHP's last-assignment precedence across repeated alias directives. +#[test] +fn test_pdo_ini_dsn_alias_last_assignment_wins() { + let out = compile_and_run_with_php_ini( + r#"getAttribute(PDO::ATTR_DRIVER_NAME); +"#, + "pdo.dsn.database = \"unknown:value\"\npdo.dsn.database = \"sqlite::memory:\"\n", + ); + assert_eq!(out, "sqlite"); +} + +/// Resolves aliases before PHP 8.4 factory/subclass validation so both entry +/// points select and enforce the class belonging to the configured driver. +#[test] +fn test_pdo_ini_dsn_alias_drives_connect_and_subclass_dispatch() { + let out = compile_and_run_with_php_ini( + r#"prepare("SELECT 7 AS MiXeD, '' AS EmPtY, NULL AS NuLlVaLuE"); +$db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, true); +$db->setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_EMPTY_STRING); +$db->setAttribute(PDO::ATTR_CASE, PDO::CASE_LOWER); +$stmt->execute(); +$row = $stmt->fetch(PDO::FETCH_ASSOC); +echo gettype($row["mixed"]) . ":" . $row["mixed"] . "|"; +echo gettype($row["empty"]) . "|" . gettype($row["nullvalue"]); +"#, + ); + assert_eq!(out, "string:7|NULL|NULL"); +} + +/// F-SQLT-05: php-src validates the extension name as an ARGUMENT, before any driver +/// dispatch — `pdo_sqlite.c:80-87` is `if (ZSTR_LEN(extension) == 0) { +/// zend_argument_must_not_be_empty_error(1); RETURN_THROWS(); }`, a `ValueError`. elephc +/// used to hand `""` straight to `sqlite3_load_extension()` and surface its failure as a +/// generic `PDOException`: the wrong exception class, raised at the wrong stage. No +/// extension file is touched by this test — the guard fires first, which is the finding. +#[test] +fn test_pdo_sqlite_load_extension_empty_name_throws_value_error() { + let out = compile_and_run( + r#"loadExtension(""); + echo "no-throw"; +} catch (\ValueError $e) { + $msg = $e->getMessage(); + echo $msg; +} +"#, + ); + assert_eq!( + out, + "Pdo\\Sqlite::loadExtension(): Argument #1 ($name) must not be empty" + ); +} + +/// F-SURF-01: `ext/pdo/pdo.stub.php` declares a GLOBAL `pdo_drivers(): array` alongside +/// the class surface — the procedural spelling of `PDO::getAvailableDrivers()`, and the +/// one most capability probes still reach for (`in_array('pgsql', pdo_drivers(), true)`). +/// It was absent entirely, so such a probe failed to COMPILE rather than reporting the +/// drivers this build has. The two spellings must agree exactly (the prelude duplicates +/// the list rather than delegating, so this equality is the thing keeping them in +/// lockstep). +#[test] +fn test_pdo_drivers_function_matches_get_available_drivers() { + let out = compile_and_run( + r#">" + ); +} + +/// F-SURF-03: the 7 `PDO::PARAM_EVT_*` constants. Their values are the DECLARATION ORDER +/// of `enum pdo_param_event` in php-src's `ext/pdo/php_pdo_driver.h` (the enum carries no +/// explicit values, so the order is the only thing that fixes them): ALLOC=0, FREE=1, +/// EXEC_PRE=2, EXEC_POST=3, FETCH_PRE=4, FETCH_POST=5, NORMALIZE=6. +/// +/// They back native PDO-DRIVER authorship — a driver's `param_hook` fires once per event +/// — and are entirely INERT in elephc, which implements the drivers natively in Rust and +/// exposes no param-hook seam to PHP. They exist so code that references them (portable +/// driver shims, suites enumerating the class surface) still compiles, which is exactly +/// what this test proves. +#[test] +fn test_pdo_param_evt_constants_present() { + let out = compile_and_run( + r#"marker = $marker . ":" . $this->queryString; + echo "ctor:" . $this->marker . "|"; + } +} +class InheritedStatement extends PrivateStatement {} + +$db = new PDO("sqlite::memory:"); +$initial = $db->getAttribute(PDO::ATTR_STATEMENT_CLASS); +echo $initial[0] . "|" . count($initial) . "|"; +echo ($db->setAttribute(PDO::ATTR_STATEMENT_CLASS, [InheritedStatement::class, ["default"]]) ? "set" : "no") . "|"; +$stored = $db->getAttribute(PDO::ATTR_STATEMENT_CLASS); +echo $stored[0] . "|" . $stored[1][0] . "|"; +$first = $db->prepare("SELECT 1"); +echo (($first instanceof InheritedStatement) ? "InheritedStatement" : "wrong") . "|"; +$second = $db->prepare("SELECT 2", [PDO::ATTR_STATEMENT_CLASS => [PDOStatement::class]]); +echo (($second instanceof PDOStatement) ? "PDOStatement" : "wrong") . "|" . $second->queryString . "|"; +echo $db->getAttribute(PDO::ATTR_STATEMENT_CLASS)[0]; +"#, + ); + assert_eq!( + out, + "PDOStatement|1|set|InheritedStatement|default|ctor:default:SELECT 1|InheritedStatement|PDOStatement|SELECT 2|InheritedStatement" + ); +} + +/// `ATTR_STATEMENT_CLASS` rejects malformed values, unrelated classes, and public +/// constructors with php-src's distinct diagnostics instead of silently storing them. +#[test] +fn test_pdo_statement_class_validation_errors() { + let out = compile_and_run( + r#" "bad", + "empty" => [], + "null-class" => [null], + "unknown" => ["NoSuchStatement"], + "parent" => [NotAStatement::class], + "public" => [PublicStatement::class], + "args" => [PDOStatement::class, "bad"], + "null-args" => [PDOStatement::class, null], +]; +foreach ($cases as $name => $value) { + try { + $db->setAttribute(PDO::ATTR_STATEMENT_CLASS, $value); + echo $name . ":none|"; + } catch (Throwable $e) { + echo $name . ":" . get_class($e) . ":" . $e->getMessage() . "|"; + } +} +"#, + ); + assert_eq!( + out, + "scalar:TypeError:PDO::setAttribute(): Argument #2 ($value) PDO::ATTR_STATEMENT_CLASS value must be of type array, string given|empty:ValueError:PDO::setAttribute(): Argument #2 ($value) PDO::ATTR_STATEMENT_CLASS value must be an array with the format array(classname, constructor_args)|null-class:TypeError:PDO::setAttribute(): Argument #2 ($value) PDO::ATTR_STATEMENT_CLASS class must be a valid class|unknown:TypeError:PDO::setAttribute(): Argument #2 ($value) PDO::ATTR_STATEMENT_CLASS class must be a valid class|parent:TypeError:PDO::setAttribute(): Argument #2 ($value) PDO::ATTR_STATEMENT_CLASS class must be derived from PDOStatement|public:TypeError:PDO::setAttribute(): Argument #2 ($value) User-supplied statement class cannot have a public constructor|args:TypeError:PDO::setAttribute(): Argument #2 ($value) PDO::ATTR_STATEMENT_CLASS constructor_args must be of type ?array, array given|null-args:TypeError:PDO::setAttribute(): Argument #2 ($value) PDO::ATTR_STATEMENT_CLASS constructor_args must be of type ?array, array given|" + ); +} + +/// Abstract statement classes are accepted as an attribute value but fail when prepare() +/// reaches instantiation, while constructor arguments for a class without a user +/// constructor fail with PDO's dedicated runtime Error. +#[test] +fn test_pdo_statement_class_instantiation_errors() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_STATEMENT_CLASS, [AbstractStatement::class]) ? "abstract-set" : "abstract-no") . "|"; +try { + $db->prepare("SELECT 1"); +} catch (Throwable $e) { + echo get_class($e) . ":" . $e->getMessage() . "|"; +} +try { + $db->prepare("SELECT 2", [PDO::ATTR_STATEMENT_CLASS => [NoConstructorStatement::class, []]]); +} catch (Throwable $e) { + echo get_class($e) . ":" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "abstract-set|Error:Cannot instantiate abstract class AbstractStatement|Error:User-supplied statement does not accept constructor arguments" + ); +} + +/// Persistent PDO handles reject a connection-level ATTR_STATEMENT_CLASS both through +/// setAttribute() and constructor options, while php-src still permits a prepare-local +/// override because it is not retained on the pooled connection. +#[test] +fn test_pdo_statement_class_persistent_connection_rules() { + let out = compile_and_run( + r#" true]); +try { + $db->setAttribute(PDO::ATTR_STATEMENT_CLASS, [PDOStatement::class]); + echo "set:none|"; +} catch (PDOException $e) { + echo "set:" . $e->getMessage() . "|"; +} +$stmt = $db->prepare("SELECT 1", [PDO::ATTR_STATEMENT_CLASS => [PDOStatement::class]]); +echo (($stmt instanceof PDOStatement) ? "local-ok" : "local-bad") . "|"; +try { + $other = new PDO("sqlite::memory:", null, null, [ + PDO::ATTR_STATEMENT_CLASS => [PDOStatement::class], + PDO::ATTR_PERSISTENT => true, + ]); + echo "ctor:none"; +} catch (PDOException $e) { + echo "ctor:" . $e->getMessage(); +} +"#, + ); + assert_eq!( + out, + "set:SQLSTATE[HY000]: General error: PDO::ATTR_STATEMENT_CLASS cannot be used with persistent PDO instances|local-ok|ctor:SQLSTATE[HY000]: General error: PDO::ATTR_STATEMENT_CLASS cannot be used with persistent PDO instances" + ); +} diff --git a/tests/codegen/pdo_cubrid.rs b/tests/codegen/pdo_cubrid.rs new file mode 100644 index 0000000000..132c983ed4 --- /dev/null +++ b/tests/codegen/pdo_cubrid.rs @@ -0,0 +1,136 @@ +//! Purpose: +//! End-to-end version-surface and live CCI tests for the optional PDO_CUBRID backend. +//! +//! Called from: +//! - `cargo test --features pdo-cubrid --test codegen_tests`. +//! +//! Key details: +//! - Surface tests never require CCI or a running server. +//! - The ignored live test requires `CUBRID_CCI_LIBRARY` and `ELEPHC_CUBRID_DSN`. + +use crate::support::*; +use elephc::php_version::PhpVersion; + +/// Keeps the external extension's historical PDO-only surface on every PHP target. +#[test] +fn test_pdo_cubrid_surface_all_php_versions() { + for version in PhpVersion::ALL { + let out = compile_and_run_with_php_version( + r#"bindParam(1, $status, PDO::PARAM_STR, 0, "ENUM"); + $statement->bindParam(2, $tags, PDO::PARAM_STR, 0, "STRING"); +} +"#, + version, + ); + assert_eq!( + out, + "cubrid,mysql,pgsql,sqlite|1000:1001:1002|4:5:6|1:16:20|missing" + ); + } +} + +/// Exercises CCI prepares, binds, scrolling, metadata, schema rows, and rollback. +#[test] +#[ignore] +fn test_pdo_cubrid_live_round_trip() { + let dsn = std::env::var("ELEPHC_CUBRID_DSN") + .expect("ELEPHC_CUBRID_DSN is required for the ignored PDO_CUBRID live test"); + let source = format!( + r#" PDO::ERRMODE_EXCEPTION]); +echo $db->getAttribute(PDO::ATTR_DRIVER_NAME) . "|"; +echo (($db->getAttribute(PDO::ATTR_SERVER_VERSION) !== "" && $db->getAttribute(PDO::ATTR_CLIENT_VERSION) !== "") ? "versions" : "missing") . "|"; +$db->setAttribute(PDO::CUBRID_ATTR_LOCK_TIMEOUT, 4); +$db->setAttribute(PDO::CUBRID_ATTR_ISOLATION_LEVEL, PDO::TRAN_REP_CLASS_REP_INSTANCE); +$db->setAttribute(PDO::ATTR_TIMEOUT, 3); +echo (($db->getAttribute(PDO::CUBRID_ATTR_LOCK_TIMEOUT) == 4 + && $db->getAttribute(PDO::CUBRID_ATTR_ISOLATION_LEVEL) == PDO::TRAN_REP_CLASS_REP_INSTANCE + && $db->getAttribute(PDO::ATTR_TIMEOUT) == 3 + && is_int($db->getAttribute(PDO::CUBRID_ATTR_MAX_STRING_LENGTH))) ? "attrs" : "bad-attrs") . "|"; +$stage = "ddl"; +$db->exec("DROP TABLE IF EXISTS elephc_pdo_cubrid"); +$db->exec("CREATE TABLE elephc_pdo_cubrid (id INTEGER AUTO_INCREMENT PRIMARY KEY, label VARCHAR(80), amount DOUBLE, status ENUM('ready', 'done'), tags SET(VARCHAR(20)), payload BLOB, note CLOB)"); +$stage = "bind"; +$insert = $db->prepare("INSERT INTO elephc_pdo_cubrid(label, amount, status, tags, payload, note) VALUES (:label, :amount, :status, :tags, :payload, :note)"); +$insert->bindValue(":label", "éléphant", PDO::PARAM_STR); +$insert->bindValue(":amount", 12.5); +$status = "ready"; +$tags = ["one", "two"]; +$payload = fopen("php://memory", "r+"); +fwrite($payload, "blob-data"); +rewind($payload); +$note = fopen("php://memory", "r+"); +fwrite($note, "clob-data"); +rewind($note); +$insert->bindParam(":status", $status, PDO::PARAM_STR, 0, "ENUM"); +$insert->bindParam(":tags", $tags, PDO::PARAM_STR, 0, "STRING"); +$insert->bindParam(":payload", $payload, PDO::PARAM_LOB, 0, "BLOB"); +$insert->bindParam(":note", $note, PDO::PARAM_LOB, 0, "CLOB"); +echo "inputs:" . ftell($payload) . ":" . ftell($note) . "|"; +$stage = "execute"; +$insert->execute(); +echo $insert->rowCount() . ":" . $db->lastInsertId() . "|"; +fclose($payload); +fclose($note); +$stage = "select-prepare"; +$select = $db->prepare("SELECT id, label, amount, status, payload, note FROM elephc_pdo_cubrid WHERE id = :id ORDER BY id", [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]); +$stage = "select-bind"; +$select->bindValue(":id", 1, PDO::PARAM_INT); +$stage = "select-execute"; +$select->execute(); +$stage = "select-fetch"; +$row = $select->fetch(PDO::FETCH_ASSOC, PDO::FETCH_ORI_FIRST); +echo $row["id"] . ":" . $row["label"] . ":" . $row["amount"] . ":" . $row["status"] . "|"; +$fetchedPayload = $row["payload"]; +$fetchedNote = $row["note"]; +echo (is_resource($fetchedPayload) ? stream_get_contents($fetchedPayload) : "not-resource") . ":"; +echo (is_resource($fetchedNote) ? stream_get_contents($fetchedNote) : "not-resource") . "|"; +fclose($fetchedPayload); +fclose($fetchedNote); +$select->execute(); +echo $select->fetchColumn() . "|"; +$absolute = $select->fetch(PDO::FETCH_NUM, PDO::FETCH_ORI_ABS, 1); +echo $absolute[0] . "|"; +$stage = "metadata"; +$meta = $select->getColumnMeta(0); +echo $meta["name"] . ":" . $meta["primary_key"] . ":" . $meta["auto_increment"] . "|"; +$schema = $db->cubrid_schema(PDO::CUBRID_SCH_TABLE, "elephc_pdo_cubrid"); +echo ($schema !== false && isset($schema[0]) ? "schema" : "missing") . "|"; +$stage = "transaction"; +$db->beginTransaction(); +$db->exec("INSERT INTO elephc_pdo_cubrid(label, amount) VALUES ('rollback', 1.0)"); +$db->rollBack(); +$countStatement = $db->query("SELECT COUNT(*) FROM elephc_pdo_cubrid"); +echo $countStatement->fetchColumn() . ":"; +echo $db->getAttribute(PDO::ATTR_AUTOCOMMIT) . ":"; +echo $db->quote("a'b"); +$select->closeCursor(); +$countStatement->closeCursor(); +$select->__destruct(); +$countStatement->__destruct(); +$insert->__destruct(); +$db->exec("DROP TABLE elephc_pdo_cubrid"); +echo "|dropped"; +$db->__destruct(); +echo "|closed"; +}} catch (Throwable $error) {{ + echo "error@" . $stage . "[" . $available . "]:" . $error->getMessage(); +}} +"# + ); + let out = compile_and_run(&source); + assert_eq!( + out, + "cubrid|versions|attrs|inputs:0:0|1:1|1:éléphant:12.5000000000000000:ready|blob-data:clob-data|1|1|id::|schema|1:1:a''b|dropped|closed" + ); +} diff --git a/tests/codegen/pdo_dblib.rs b/tests/codegen/pdo_dblib.rs new file mode 100644 index 0000000000..9bc956d81c --- /dev/null +++ b/tests/codegen/pdo_dblib.rs @@ -0,0 +1,101 @@ +//! Purpose: +//! End-to-end surface and live-server tests for the optional FreeTDS PDO_DBLIB backend. +//! +//! Called from: +//! - `cargo test --features pdo-dblib --test codegen_tests`. +//! +//! Key details: +//! - Surface tests require FreeTDS at link time but no database server. +//! - Ignored live tests read `ELEPHC_DBLIB_DSN` and exercise SQL Server/Sybase through libsybdb. + +use crate::support::*; +use elephc::php_version::PhpVersion; + +/// Exposes DBLIB in the compiled-driver registry and PHP 8.4 namespaced class. +#[test] +fn test_pdo_dblib_surface_php84() { + let out = compile_and_run_with_php_version( + r#" PDO::ERRMODE_EXCEPTION]); +echo $db->getAttribute(PDO::ATTR_DRIVER_NAME) . "|"; +echo ($db->getAttribute(PDO::ATTR_EMULATE_PREPARES) ? "emulated" : "native") . ":"; +echo ($db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false) ? "mutable" : "fixed") . ":"; +echo ($db->setAttribute(Pdo\Dblib::ATTR_QUERY_TIMEOUT, 5) ? "timeout" : "no-timeout") . "|"; +$db->setAttribute(Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER, true); +$db->setAttribute(Pdo\Dblib::ATTR_SKIP_EMPTY_ROWSETS, true); +echo ($db->getAttribute(Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER) ? "uuid" : "binary") . ":"; +echo ($db->getAttribute(Pdo\Dblib::ATTR_SKIP_EMPTY_ROWSETS) ? "skip" : "keep") . ":"; +echo (($db->getAttribute(Pdo\Dblib::ATTR_VERSION) !== "" && $db->getAttribute(Pdo\Dblib::ATTR_TDS_VERSION) !== "") ? "versions" : "missing") . "|"; +echo ($db->setAttribute(Pdo\Dblib::ATTR_CONNECTION_TIMEOUT, 1) ? "mutable-connect" : "fixed-connect") . ":"; +try {{ + $db->getAttribute(PDO::ATTR_CLIENT_VERSION); +}} catch (PDOException $e) {{ + echo $e->errorInfo[0]; +}} +echo "|"; +echo $db->quote("O'Brien") . ":"; +$db->setAttribute(PDO::ATTR_DEFAULT_STR_PARAM, PDO::PARAM_STR_NATL); +echo $db->quote("O'Brien") . "|"; +$stmt = $db->prepare("SELECT :n AS n, :name AS name"); +$stmt->execute(["n" => 7, "name" => "Ada"]); +$row = $stmt->fetch(PDO::FETCH_ASSOC); +echo gettype($row["n"]) . ":" . $row["n"] . ":" . $row["name"] . "|"; +$meta = $stmt->getColumnMeta(0); +echo $meta["native_type"] . ":" . $meta["native_type_id"] . ":" . $meta["pdo_type"] . ":" . $meta["max_length"] . "|"; +$types = $db->query("SELECT CAST('00112233-4455-6677-8899-AABBCCDDEEFF' AS uniqueidentifier) AS uuid, CAST('2024-02-03T04:05:06' AS datetime2) AS moment")->fetch(PDO::FETCH_NUM); +echo $types[0] . ":" . $types[1] . "|"; +$sets = $db->query("SELECT 1 AS value; SELECT 2 AS value"); +echo $sets->fetchColumn() . ":"; +echo ($sets->nextRowset() ? $sets->fetchColumn() : "missing") . "|"; +try {{ + $db->query("SELECT * FROM elephc_missing_dblib_table"); +}} catch (PDOException $e) {{ + echo $e->errorInfo[0] . ":" . (($e->errorInfo[1] !== 0) ? "native" : "zero") . ":" . (isset($e->errorInfo[4]) ? "extended" : "short"); +}} +echo "|"; +try {{ + $db->prepare("SELECT ? AS positional, :named AS named"); +}} catch (PDOException $e) {{ + echo $e->errorInfo[0]; +}} +}} catch (Throwable $fatal) {{ + echo "FAIL:" . $fatal->getMessage(); +}} +"# + ); + let out = compile_and_run(&source); + assert_eq!(out, "dblib|emulated:fixed:timeout|uuid:skip:versions|fixed-connect:IM001|'O''Brien':N'O''Brien'|integer:7:Ada|int:56:1:4|00112233-4455-6677-8899-AABBCCDDEEFF:2024-02-03 04:05:06|1:2|HY000:native:extended|HY093"); +} diff --git a/tests/codegen/pdo_firebird.rs b/tests/codegen/pdo_firebird.rs new file mode 100644 index 0000000000..eb9289f438 --- /dev/null +++ b/tests/codegen/pdo_firebird.rs @@ -0,0 +1,93 @@ +//! Purpose: +//! End-to-end surface and live-server tests for the optional PDO_FIREBIRD backend. +//! +//! Called from: +//! - `cargo test --features pdo-firebird --test codegen_tests`. +//! +//! Key details: +//! - Surface tests need no server and verify the PHP-version-dependent aliases/classes. +//! - The ignored live test reads `ELEPHC_FIREBIRD_DSN` and exercises Firebird over its wire protocol. + +use crate::support::*; +use elephc::php_version::PhpVersion; + +/// Exposes the namespaced Firebird class, constants, API level, and legacy aliases on PHP 8.4. +#[test] +fn test_pdo_firebird_surface_php84() { + let out = compile_and_run_with_php_version( + r#" PDO::ERRMODE_EXCEPTION]); +echo $db->getAttribute(PDO::ATTR_DRIVER_NAME) . "|"; +echo (($db->getAttribute(PDO::ATTR_SERVER_VERSION) !== "" && $db->getAttribute(PDO::ATTR_CLIENT_VERSION) !== "") ? "versions" : "missing") . "|"; +echo ($db->getAttribute(PDO::ATTR_CONNECTION_STATUS) ? "connected" : "closed") . "|"; +$db->setAttribute(Pdo\Firebird::ATTR_DATE_FORMAT, "%d/%m/%Y"); +$db->setAttribute(Pdo\Firebird::TRANSACTION_ISOLATION_LEVEL, Pdo\Firebird::READ_COMMITTED); +$db->setAttribute(Pdo\Firebird::WRITABLE_TRANSACTION, true); +echo $db->getAttribute(Pdo\Firebird::ATTR_DATE_FORMAT) . ":" . $db->getAttribute(Pdo\Firebird::TRANSACTION_ISOLATION_LEVEL) . ":" . ($db->getAttribute(Pdo\Firebird::WRITABLE_TRANSACTION) ? "rw" : "ro") . "|"; +echo $db->quote("O'Brien") . "|"; +try {{ + $stmt = $db->prepare("SELECT CAST(? AS INTEGER) AS n, CAST(:name AS VARCHAR(20)) AS name FROM RDB\$DATABASE"); + $stmt->execute([7, "name" => "Ada"]); +}} catch (PDOException $mixed) {{ + echo $mixed->errorInfo[0] . "|"; +}} +$stmt = $db->prepare("SELECT CAST(:n AS INTEGER) AS n, CAST(:name AS VARCHAR(20)) AS name FROM RDB\$DATABASE"); +$stmt->execute(["n" => 7, "name" => "Ada"]); +$row = $stmt->fetch(PDO::FETCH_ASSOC); +echo gettype($row["N"]) . ":" . $row["N"] . ":" . trim($row["NAME"]) . "|"; +$meta = $stmt->getColumnMeta(0); +echo (is_array($meta) ? count($meta) : 0) . ":" . $meta["pdo_type"] . "|"; +$date = $db->query("SELECT CAST('2024-02-03' AS DATE) AS d FROM RDB\$DATABASE")->fetchColumn(); +echo $date . "|"; +$db->exec("RECREATE GLOBAL TEMPORARY TABLE ELEPHC_PDO_FB_TEST (VAL INTEGER) ON COMMIT PRESERVE ROWS"); +$db->beginTransaction(); +$db->exec("INSERT INTO ELEPHC_PDO_FB_TEST (VAL) VALUES (42)"); +$db->rollBack(); +echo $db->query("SELECT COUNT(*) FROM ELEPHC_PDO_FB_TEST")->fetchColumn() . "|"; +try {{ + $db->query("SELECT * FROM ELEPHC_MISSING_FIREBIRD_TABLE"); +}} catch (PDOException $e) {{ + echo $e->errorInfo[0] . ":" . (($e->errorInfo[1] !== 0) ? "native" : "zero"); +}} +}} catch (Throwable $fatal) {{ +echo "FAIL:" . $fatal->getMessage(); +}} +"# + ); + let out = compile_and_run(&source); + assert_eq!(out, "firebird|versions|connected|%d/%m/%Y:1004:rw|'O''Brien'|HY093|integer:7:Ada|1:1|03/02/2024|0|42S02:native"); +} diff --git a/tests/codegen/pdo_ibm.rs b/tests/codegen/pdo_ibm.rs new file mode 100644 index 0000000000..46d454846d --- /dev/null +++ b/tests/codegen/pdo_ibm.rs @@ -0,0 +1,83 @@ +//! Purpose: +//! End-to-end version-surface and live-server tests for the optional PDO_IBM backend. +//! +//! Called from: +//! - `cargo test --features pdo-ibm --test codegen_tests`. +//! +//! Key details: +//! - Surface tests need unixODBC at link time but no IBM driver installation. +//! - The ignored live test requires an IBM CLI/ODBC driver plus `ELEPHC_IBM_DSN`. + +use crate::support::*; +use elephc::php_version::PhpVersion; + +/// Keeps legacy PDO_IBM constants while omitting the PHP 8.4 namespaced class on PHP 8.3. +#[test] +fn test_pdo_ibm_surface_php83() { + let out = compile_and_run_with_php_version( + r#"getMessage(), '"sqlite" driver') ? "guard" : "wrong"; } +"#, + PhpVersion::Php84, + ); + assert_eq!(out, "1281:1282:1283:1284|2561:2562:2563|guard"); +} + +/// Keeps the PHP 8.5 alias values coherent with the namespaced PDO_IBM constants. +#[test] +fn test_pdo_ibm_surface_php85() { + let out = compile_and_run_with_php_version( + r#" PDO::ERRMODE_EXCEPTION]); +echo $db->getAttribute(PDO::ATTR_DRIVER_NAME) . "|"; +echo $db->getAttribute(PDO::ATTR_CLIENT_VERSION) . ":" . $db->getAttribute(PDO::ATTR_SERVER_INFO) . "|"; +$stmt = $db->prepare("SELECT CAST(42 AS INTEGER) AS answer, CAST('elephc' AS VARCHAR(20)) AS label FROM SYSIBM.SYSDUMMY1", [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]); +$stmt->setAttribute(PDO::ATTR_CURSOR_NAME, "ELEPHC_IBM_CURSOR"); +$stmt->execute(); +$meta = $stmt->getColumnMeta(0); +echo $meta["name"] . ":" . $meta["native_type"] . ":" . $meta["len"] . ":" . $meta["precision"] . ":"; +echo (array_key_exists("not_null", $meta["flags"]) && array_key_exists("unsigned", $meta["flags"]) && array_key_exists("auto_increment", $meta["flags"]) ? "flags" : "missing") . "|"; +$row = $stmt->fetch(PDO::FETCH_ASSOC); +echo gettype($row["ANSWER"]) . ":" . $row["ANSWER"] . ":" . $row["LABEL"] . "|"; +echo $stmt->getAttribute(PDO::ATTR_CURSOR_NAME); +}} catch (Throwable $fatal) {{ echo "FAIL:" . $fatal->getMessage(); }} +"# + ); + let out = compile_and_run(&source); + assert!(out.starts_with("ibm|1.7.0:"), "unexpected PDO_IBM output: {out}"); + assert!(out.contains(":flags|string:42:elephc|ELEPHC_IBM_CURSOR"), "unexpected PDO_IBM output: {out}"); +} diff --git a/tests/codegen/pdo_informix.rs b/tests/codegen/pdo_informix.rs new file mode 100644 index 0000000000..9d54e3f422 --- /dev/null +++ b/tests/codegen/pdo_informix.rs @@ -0,0 +1,79 @@ +//! Purpose: +//! End-to-end surface and live-server tests for the optional PDO_INFORMIX backend. +//! +//! Called from: +//! - `cargo test --features pdo-informix --test codegen_tests`. +//! +//! Key details: +//! - Surface tests need unixODBC at link time but no Informix installation. +//! - The ignored live test requires IBM/HCL Client SDK plus `ELEPHC_INFORMIX_DSN`. + +use crate::support::*; +use elephc::php_version::PhpVersion; + +/// Exposes the current PECL driver without inventing a namespaced subclass. +#[test] +fn test_pdo_informix_surface_php84() { + let out = compile_and_run_with_php_version( + r#" PDO::ERRMODE_EXCEPTION]); +echo $db->getAttribute(PDO::ATTR_DRIVER_NAME) . "|"; +echo $db->getAttribute(PDO::ATTR_CLIENT_VERSION) . ":" . $db->getAttribute(PDO::ATTR_SERVER_INFO) . "|"; +$db->exec("CREATE TEMP TABLE elephc_informix_test (id INTEGER, name VARCHAR(40)) WITH NO LOG"); +$stmt = $db->prepare("INSERT INTO elephc_informix_test (id, name) VALUES (:id, :name)"); +$stmt->execute(["id" => 7, "name" => "Éléphant"]); +echo $stmt->rowCount() . "|"; +$stmt = $db->query("SELECT id, name FROM elephc_informix_test ORDER BY id"); +$meta = $stmt->getColumnMeta(0); +echo $meta["scale"] . ":" . $meta["native_type"] . ":"; +echo (array_key_exists("not_null", $meta["flags"]) && array_key_exists("unsigned", $meta["flags"]) + && array_key_exists("auto_increment", $meta["flags"]) ? "flags" : "missing") . ":"; +echo $meta["pdo_type"] . ":"; +echo (array_key_exists("name", $meta) && array_key_exists("len", $meta) + && array_key_exists("precision", $meta) ? "core" : "missing") . "|"; +$row = $stmt->fetch(PDO::FETCH_ASSOC); +echo implode(",", array_keys($row)) . ":" . gettype($row["ID"]) . ":" . $row["ID"] . ":" . $row["NAME"] . "|"; +$db->beginTransaction(); +$db->exec("INSERT INTO elephc_informix_test (id, name) VALUES (8, 'rollback')"); +$db->rollBack(); +echo $db->query("SELECT COUNT(*) FROM elephc_informix_test")->fetchColumn() . "|"; +try {{ $db->query("SELECT * FROM elephc_missing_table"); }} catch (PDOException $e) {{ + echo $e->errorInfo[0] . ":" . (($e->errorInfo[1] !== 0) ? "native" : "zero"); +}} +}} catch (Throwable $fatal) {{ +echo "FAIL:" . $fatal->getMessage(); +}} +"# + ); + let out = compile_and_run(&source); + assert!(out.starts_with("informix|1.3.7:"), "unexpected PDO_INFORMIX output: {out}"); + assert!(out.contains("|1|0:INTEGER:flags:2:core|ID,NAME:string:7:Éléphant|1|"), "unexpected PDO_INFORMIX output: {out}"); + assert!(out.ends_with(":native"), "unexpected PDO_INFORMIX output: {out}"); +} diff --git a/tests/codegen/pdo_mysql.rs b/tests/codegen/pdo_mysql.rs index 675e3e533d..54c3953800 100644 --- a/tests/codegen/pdo_mysql.rs +++ b/tests/codegen/pdo_mysql.rs @@ -36,6 +36,165 @@ fn my_program(body: &str) -> String { ) } +/// Generic connection-information attributes expose the linked client, live +/// server statistics, and the actual MySQL transport description. +#[test] +#[ignore] +fn test_mysql_connection_information_attributes() { + let out = compile_and_run(&my_program( + r#" +$client = (string) $db->getAttribute(PDO::ATTR_CLIENT_VERSION); +$server = (string) $db->getAttribute(PDO::ATTR_SERVER_VERSION); +$info = (string) $db->getAttribute(PDO::ATTR_SERVER_INFO); +$status = (string) $db->getAttribute(PDO::ATTR_CONNECTION_STATUS); +echo (strpos($client, "mysql ") === 0 ? "client" : "bad-client") . "|"; +echo (strlen($server) > 0 ? "server" : "bad-server") . "|"; +echo (strpos($info, "Uptime: ") === 0 && strpos($info, "Questions: ") !== false ? "info" : "bad-info") . "|"; +echo (strpos($status, " via TCP/IP") !== false || $status === "Localhost via UNIX socket" ? "status" : "bad-status"); +"#, + )); + assert_eq!(out, "client|server|info|status"); +} + +/// MySQL's live `ATTR_FETCH_TABLE_NAMES` setting prefixes fetched keys with the +/// protocol table label and affects statements prepared after either constructor +/// or runtime configuration. +#[test] +#[ignore] +fn test_mysql_fetch_table_names_attribute() { + let out = compile_and_run(&my_program( + r#" +$db->exec("DROP TABLE IF EXISTS my_names"); +$db->exec("CREATE TABLE my_names (id INT)"); +$db->exec("INSERT INTO my_names VALUES (7)"); +echo ($db->getAttribute(PDO::ATTR_FETCH_TABLE_NAMES) ? "on" : "off") . "|"; +$db->setAttribute(PDO::ATTR_FETCH_TABLE_NAMES, true); +$row = $db->query("SELECT id FROM my_names")->fetch(PDO::FETCH_ASSOC); +echo $row["my_names.id"] . "|" . ($db->getAttribute(PDO::ATTR_FETCH_TABLE_NAMES) ? "on" : "off"); +$db->setAttribute(PDO::ATTR_FETCH_TABLE_NAMES, false); +$plain = $db->query("SELECT id FROM my_names")->fetch(PDO::FETCH_ASSOC); +echo "|" . $plain["id"]; +$db->exec("DROP TABLE my_names"); +"#, + )); + assert_eq!(out, "off|7|on|7"); +} + +/// MySQL unbuffered mode reports zero SELECT rowCount, blocks a second query +/// until the active cursor is closed, and can be toggled live through the PDO +/// attribute. MULTI_STATEMENTS=false rejects real second statements while +/// allowing a semicolon embedded in a string literal. +#[test] +#[ignore] +fn test_mysql_buffered_and_multi_statement_options() { + let out = compile_and_run( + r#" false, + \Pdo\Mysql::ATTR_MULTI_STATEMENTS => false, + \Pdo\Mysql::ATTR_IGNORE_SPACE => true, +]); +$stmt = $db->query("SELECT 1 AS n UNION ALL SELECT 2"); +$first = $stmt->fetchColumn(); +try { + $db->query("SELECT 3"); + $busy = "bad"; +} catch (\PDOException $error) { + $busy = ($error->errorInfo[1] === 2014) ? "busy" : "wrong"; +} +$stmt->closeCursor(); +$after = $db->query("SELECT ';' AS value;")->fetchColumn(); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); +$multi = $db->query("SELECT 1; SELECT 2") === false ? "blocked" : "bad"; +echo ($db->getAttribute(\Pdo\Mysql::ATTR_USE_BUFFERED_QUERY) ? "buffered" : "unbuffered") + . ":" . $first . ":" . $busy . ":" . $after . ":" . $multi; +"#, + ); + assert_eq!(out, "unbuffered:1:busy:;:blocked"); +} + +/// Unbuffered MySQL execution returns after the first wire row instead of waiting +/// for a deliberately delayed second row, proving rows are not materialized first. +#[test] +#[ignore] +fn test_mysql_unbuffered_fetch_is_demand_driven() { + let out = compile_and_run( + r#" false, + \PDO::ATTR_EMULATE_PREPARES => false, +]); +$db->exec("DROP TABLE IF EXISTS elephc_stream_probe"); +$db->exec("CREATE TABLE elephc_stream_probe (id INT PRIMARY KEY)"); +$db->exec("INSERT INTO elephc_stream_probe VALUES (1), (2)"); +$stmt = $db->prepare("SELECT IF(id = 1, REPEAT('x', 1048576), CONCAT(SLEEP(3), 'done')) AS payload FROM elephc_stream_probe"); +$start = microtime(true); +$stmt->execute(); +$executeElapsed = microtime(true) - $start; +$first = $stmt->fetchColumn(); +$second = $stmt->fetchColumn(); +$totalElapsed = microtime(true) - $start; +$stmt->closeCursor(); +$db->exec("DROP TABLE elephc_stream_probe"); +echo ($executeElapsed < 2.0 ? "early" : "late") . ":" . strlen($first) . ":" . $second . ":" . ($totalElapsed >= 2.5 ? "waited" : "too-fast"); +"#, + ); + assert_eq!(out, "early:1048576:0done:waited"); +} + +/// Unbuffered stored-procedure result sets remain separated and `nextRowset()` +/// discards unread rows before advancing, including MySQL's trailing OK set. +#[test] +#[ignore] +fn test_mysql_unbuffered_next_rowset_is_demand_driven() { + let out = compile_and_run( + r#" false, +]); +$db->exec("DROP PROCEDURE IF EXISTS elephc_stream_sets"); +$db->exec("CREATE PROCEDURE elephc_stream_sets() BEGIN SELECT 1 AS n UNION ALL SELECT 99; SELECT 2 AS n; END"); +$stmt = $db->query("CALL elephc_stream_sets()"); +$first = $stmt->fetchColumn(); +$stmt->nextRowset(); +$second = $stmt->fetchColumn(); +while ($stmt->nextRowset()) {} +$ready = $db->query("SELECT 3")->fetchColumn(); +$stmt->closeCursor(); +$db->exec("DROP PROCEDURE elephc_stream_sets"); +echo $first . ":" . $second . ":" . $ready; +"#, + ); + assert_eq!(out, "1:2:3"); +} + +/// With LOCAL_INFILE explicitly enabled, the native client uploads the exact +/// requested file and enforces ATTR_LOCAL_INFILE_DIRECTORY's canonical path +/// boundary. Requires a server configured with `local_infile=ON`. +#[test] +#[ignore] +fn test_mysql_local_infile_directory_upload() { + let out = compile_and_run( + r#" true, + \Pdo\Mysql::ATTR_LOCAL_INFILE_DIRECTORY => sys_get_temp_dir(), +]); +$db->exec("DROP TABLE IF EXISTS elephc_local_infile"); +$db->exec("CREATE TABLE elephc_local_infile (id INT, name VARCHAR(20))"); +$count = $db->exec("LOAD DATA LOCAL INFILE " . $db->quote($path) . " INTO TABLE elephc_local_infile"); +$names = $db->query("SELECT GROUP_CONCAT(name ORDER BY id) FROM elephc_local_infile")->fetchColumn(); +$db->exec("DROP TABLE elephc_local_infile"); +unlink($path); +echo $count . ":" . $names; +"#, + ); + assert_eq!(out, "2:Ada,Bob"); +} + /// Round-trip: create, insert through named placeholders (rewritten to `?`), and /// read a row back keyed by column name. #[test] @@ -74,6 +233,63 @@ $db->exec("DROP TABLE my_pos"); assert_eq!(out, "seven"); } +/// MySQL defaults to client-side emulated prepares, quotes bound text without losing +/// apostrophes, and switches subsequent statements to native protocol when disabled. +#[test] +#[ignore] +fn test_mysql_emulated_prepare_default_and_native_opt_out() { + let out = compile_and_run(&my_program( + r#" +$default = $db->getAttribute(PDO::ATTR_EMULATE_PREPARES); +$emulated = $db->prepare("SELECT ? AS value"); +$emulated->execute(["O'Reilly"]); +$quoted = $emulated->fetchColumn(); +$disabled = $db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); +$native = $db->prepare("SELECT ? AS value"); +$native->execute([17]); +echo ($default ? "emulated" : "native") . "|" . $quoted . "|" + . ($disabled ? "disabled" : "failed") . "|" + . ($native->getAttribute(PDO::ATTR_EMULATE_PREPARES) ? "emulated" : "native") . "|" + . $native->fetchColumn(); +"#, + )); + assert_eq!(out, "emulated|O'Reilly|disabled|native|17"); +} + +/// Emulated execution rejects a missing bind client-side with HY093 instead of +/// silently substituting SQL NULL for a parameter the caller never supplied. +#[test] +#[ignore] +fn test_mysql_emulated_prepare_rejects_missing_bind() { + let out = compile_and_run(&my_program( + r#" +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); +$stmt = $db->prepare("SELECT ? + ?"); +$ok = $stmt->execute([1]); +echo (($ok === false) ? "false" : "true") . "|" . $stmt->errorCode(); +"#, + )); + assert_eq!(out, "false|HY093"); +} + +/// `debugDumpParams()` exposes the exact SQL rendered by the emulated text-protocol +/// path, including client-side string quoting. +#[test] +#[ignore] +fn test_mysql_emulated_prepare_debug_dump_prints_sent_sql() { + let out = compile_and_run(&my_program( + r#" +$stmt = $db->prepare("SELECT ? AS value"); +$stmt->execute(["O'Reilly"]); +$stmt->debugDumpParams(); +"#, + )); + assert_eq!( + out, + "SQL: [17] SELECT ? AS value\nSent SQL: [27] SELECT 'O\\'Reilly' AS value\nParams: 1\nKey: Position #0:\nparamno=0\nname=[0] \"\"\nis_param=1\nparam_type=2\n" + ); +} + /// `AUTO_INCREMENT` columns drive `lastInsertId()`. #[test] #[ignore] @@ -152,6 +368,22 @@ $db->exec("DROP TABLE my_tx"); assert_eq!(out, "1:2"); } +/// A raw MySQL `START TRANSACTION` bypasses PDO::beginTransaction() but remains +/// visible through PDO::inTransaction() and accepted by PDO::rollBack(). +#[test] +#[ignore] +fn test_mysql_raw_transaction_is_visible() { + let out = compile_and_run(&my_program( + r#" +$db->exec("START TRANSACTION"); +echo ($db->inTransaction() ? "in" : "out") . ":"; +$db->rollBack(); +echo ($db->inTransaction() ? "in" : "out"); +"#, + )); + assert_eq!(out, "in:out"); +} + /// Rich MySQL types decode to their text representation: `DECIMAL` keeps its /// scale, `DATE` drops the time, `DATETIME` keeps it, and `TIME` renders as /// `HH:MM:SS`. The values bind through text parameters (coerced by the server to @@ -209,3 +441,853 @@ echo ":" . (($db->exec("ALSO BAD") === false) ? "false" : "other"); )); assert_eq!(out, "caught:false"); } + +/// P2-2: a `BIGINT UNSIGNED` value above `i64::MAX` round-trips as an exact +/// decimal numeric string rather than wrapping negative through a lossy `as +/// i64` cast (`my.rs::decode_value`'s `Value::UInt` branch). Driven against the +/// live server. +#[test] +#[ignore] +fn test_mysql_bigint_unsigned_above_i64_max_round_trips() { + let out = compile_and_run(&my_program( + r#" +$db->exec("DROP TABLE IF EXISTS my_bigint_unsigned"); +$db->exec("CREATE TABLE my_bigint_unsigned (n BIGINT UNSIGNED)"); +$db->exec("INSERT INTO my_bigint_unsigned VALUES (18446744073709551615)"); +echo $db->query("SELECT n FROM my_bigint_unsigned")->fetchColumn(); +$db->exec("DROP TABLE my_bigint_unsigned"); +"#, + )); + assert_eq!(out, "18446744073709551615"); +} + +/// P1-9 (minimal wiring): `Pdo\Mysql::ATTR_INIT_COMMAND` runs its SQL statement +/// right after authentication, so a session variable it sets is already visible +/// to the very first query issued on the connection. Driven against the live +/// server as the driver subclass directly (the constructor option). +#[test] +#[ignore] +fn test_mysql_attr_init_command_runs_on_connect() { + let out = compile_and_run( + r#" "SET @elephc_init_probe = 42", +]); +echo $db->query("SELECT @elephc_init_probe")->fetchColumn(); +"#, + ); + assert_eq!(out, "42"); +} + +/// P2-3: a `charset=utf8mb4` DSN key becomes a `SET NAMES utf8mb4` statement at +/// connect time, so `SHOW VARIABLES LIKE 'character_set_connection'` reports it +/// without the caller issuing any SQL itself. Driven against the live server. +#[test] +#[ignore] +fn test_mysql_charset_dsn_key_sets_connection_charset() { + let out = compile_and_run( + r#"query("SHOW VARIABLES LIKE 'character_set_connection'")->fetch(PDO::FETCH_NUM); +echo $row[1]; +"#, + ); + assert_eq!(out, "utf8mb4"); +} + +/// P2-1: `PDO::ATTR_TIMEOUT` folds into the DSN as the `connect_timeout` key +/// (mapped to `OptsBuilder::tcp_connect_timeout` in `my.rs::build_opts`), so a +/// connection attempt against an unreachable host fails within a bounded time +/// instead of hanging on the OS's own (much longer) TCP connect timeout. Uses a +/// non-routable TEST-NET-1 address (RFC 5737, `192.0.2.0/24`) so the connect +/// attempt reliably blackholes rather than getting an immediate "connection +/// refused". Driven without any live server (the point is that the connection +/// never completes). +#[test] +#[ignore] +fn test_mysql_attr_timeout_fails_fast() { + let out = compile_and_run( + r#" 2]); + echo "connected"; +} catch (PDOException $e) { + $elapsed = microtime(true) - MysqlTimeoutClock::$start; + echo ($elapsed < 10.0) ? "fast" : "slow"; +} +"#, + ); + assert_eq!(out, "fast"); +} + +/// `Pdo\Mysql::getWarningCount()` reports the warning count of the last statement, +/// cached from that statement's terminal OK packet. `CREATE TABLE IF NOT EXISTS` +/// on an existing table raises one "table already exists" warning (an OK-terminated +/// DDL statement). A lossy SELECT cast then pins EOF/OK warning capture on a prepared +/// row-producing statement too. Driven against the live server as the driver subclass. +#[test] +#[ignore] +fn test_mysql_get_warning_count() { + let out = compile_and_run( + "exec(\"DROP TABLE IF EXISTS elephc_warn_probe\");\n$db->exec(\"CREATE TABLE elephc_warn_probe (id INT)\");\n$db->exec(\"CREATE TABLE IF NOT EXISTS elephc_warn_probe (id INT)\");\n$ddl = $db->getWarningCount();\n$stmt = $db->query(\"SELECT CAST('not-a-number' AS UNSIGNED)\");\n$stmt->fetchColumn();\n$select = $db->getWarningCount();\n$db->exec(\"DROP TABLE elephc_warn_probe\");\necho $ddl . ':' . (($select > 0) ? 'warn' : 'none');\n", + ); + assert_eq!(out, "1:warn"); +} + +/// Live TLS round-trip. Opens a MySQL/MariaDB connection with `Pdo\Mysql::ATTR_SSL_CA` +/// set to the server CA bundle (path in `ELEPHC_MY_TLS_CA`) and confirms a query +/// returns over the encrypted connection. mysql 28's ring-backed TLS ships in the +/// default bridge build; a custom build without `mysql-tls` fails loudly. +/// `#[ignore]` — needs a TLS-serving MySQL. Example: +/// docker run -d --name mytls -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=testdb \ +/// -e MYSQL_USER=test -e MYSQL_PASSWORD=test -p 33062:3306 mysql:8 \ +/// --require-secure-transport=ON +/// docker cp mytls:/var/lib/mysql/ca.pem ./ca.pem # server-generated CA +/// cargo build -p elephc-pdo # TLS staticlib (ring) +/// ELEPHC_MY_TLS_DSN='mysql:host=127.0.0.1;port=33062;dbname=testdb;user=test;password=test' \ +/// ELEPHC_MY_TLS_CA="$PWD/ca.pem" \ +/// cargo test --test codegen_tests -- --ignored mysql_tls_round_trip +#[test] +#[ignore] +fn mysql_tls_round_trip() { + let out = compile_and_run( + r#" (string) getenv("ELEPHC_MY_TLS_CA")] +); +echo $db->query("SELECT 'tls-ok'")->fetchColumn(); +"#, + ); + assert_eq!(out, "tls-ok"); +} + +/// `ATTR_SSL_CAPATH` trusts the PEM certificates in a directory after the bridge +/// adapts them into rustls's multi-certificate bundle representation. +#[test] +#[ignore] +fn mysql_tls_capath_round_trip() { + let out = compile_and_run( + r#" (string) getenv("ELEPHC_MY_TLS_CAPATH")] +); +echo $db->query("SELECT 'capath-ok'")->fetchColumn(); +"#, + ); + assert_eq!(out, "capath-ok"); +} + +/// A caller-supplied caching_sha2_password RSA key is used for a non-TLS login, +/// matching mysqlnd's MYSQL_SERVER_PUBLIC_KEY connection option. +#[test] +#[ignore] +fn mysql_server_public_key_round_trip() { + let out = compile_and_run( + r#" (string) getenv("ELEPHC_MY_SERVER_PUBLIC_KEY")] +); +echo $db->query("SELECT 'rsa-ok'")->fetchColumn(); +"#, + ); + assert_eq!(out, "rsa-ok"); +} + +/// ATTR_SSL_CIPHER constrains rustls to a modern TLS 1.2 suite understood under +/// both OpenSSL's MySQL spelling and rustls's IANA spelling. +#[test] +#[ignore] +fn mysql_tls_cipher_round_trip() { + let out = compile_and_run( + r#" (string) getenv("ELEPHC_MY_TLS_CA"), + Pdo\Mysql::ATTR_SSL_CIPHER => "ECDHE-RSA-AES128-GCM-SHA256", + ] +); +$row = $db->query("SHOW STATUS LIKE 'Ssl_cipher'")->fetch(PDO::FETCH_NUM); +echo str_contains((string) $row[1], "AES128-GCM-SHA256") ? "cipher-ok" : (string) $row[1]; +"#, + ); + assert_eq!(out, "cipher-ok"); +} + +/// P0-B: `PDO::exec()` must return the real affected-row count for INSERT, +/// UPDATE, and DELETE, not always `0`. Regression for `my.rs::MyConn::exec()` +/// reading `affected_rows()` after draining the query result, at which point +/// the crate's `QueryResult` state machine has already advanced past the OK +/// packet that carries the count. Asserts the return values directly (not via +/// a follow-up `SELECT COUNT(*)`, which would pass even with the bug). Docker: +/// docker run -d --name my -e MARIADB_ROOT_PASSWORD=rootpw \ +/// -e MARIADB_DATABASE=testdb -e MARIADB_USER=test \ +/// -e MARIADB_PASSWORD=test -p 33060:3306 mariadb:11 +/// ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test' \ +/// cargo test --test codegen_tests -- --ignored mysql_exec_returns_affected_row_count +#[test] +#[ignore] +fn mysql_exec_returns_affected_row_count() { + let out = compile_and_run(&my_program( + r#" +$db->exec("DROP TABLE IF EXISTS my_exec_counts"); +$db->exec("CREATE TABLE my_exec_counts (id INTEGER PRIMARY KEY, n INTEGER)"); +$inserted = $db->exec("INSERT INTO my_exec_counts (id, n) VALUES (1, 1), (2, 1), (3, 2)"); +$updated = $db->exec("UPDATE my_exec_counts SET n = 9 WHERE n = 1"); +$deleted = $db->exec("DELETE FROM my_exec_counts WHERE n = 9"); +echo $inserted . ":" . $updated . ":" . $deleted; +$db->exec("DROP TABLE my_exec_counts"); +"#, + )); + assert_eq!(out, "3:2:2"); +} + +/// P0-D: a `BIT(8)` column holding a high-bit value must round-trip its raw +/// byte unchanged. Regression for `my.rs::ColKind::from_column_type()` routing +/// `MYSQL_TYPE_BIT` through the lossy `String::from_utf8_lossy` path (the +/// `Other` bucket) instead of the byte-preserving `Cell::Bytes` path (the +/// `Binary` bucket): `0xFF` is not valid UTF-8, so the lossy path replaces it +/// with a 3-byte U+FFFD ("\u{FFFD}") in the decoded string. Asserted via +/// `bin2hex()`, not a printable-ASCII value, since a printable value would +/// happen to survive the lossy path and mask the bug. Docker: same as +/// `mysql_exec_returns_affected_row_count` above. +#[test] +#[ignore] +fn mysql_bit_column_round_trip() { + let out = compile_and_run(&my_program( + r#" +$db->exec("DROP TABLE IF EXISTS my_bit_col"); +$db->exec("CREATE TABLE my_bit_col (b BIT(8))"); +$db->exec("INSERT INTO my_bit_col VALUES (b'11111111')"); +$val = $db->query("SELECT b FROM my_bit_col")->fetchColumn(); +echo bin2hex($val); +$db->exec("DROP TABLE my_bit_col"); +"#, + )); + assert_eq!(out, "ff"); +} + +/// P1: a `VARBINARY` column holding a high-bit, non-UTF-8 byte sequence must +/// round-trip its raw bytes unchanged. Regression for `my.rs::ColKind:: +/// from_column` classifying `VARBINARY`/`BINARY` correctly: both arrive on the +/// wire as the exact same `ColumnType` as `VARCHAR`/`CHAR` +/// (`MYSQL_TYPE_VAR_STRING`), and only the column's character set (63, the +/// `binary` collation) tells them apart. Before the fix, a `VARBINARY` column +/// fell to `ColKind::Other` and was decoded through the lossy +/// `String::from_utf8_lossy` path, turning `0xC3FF00` (not valid UTF-8) into a +/// U+FFFD-corrupted value. Asserted via `bin2hex()`, not a printable value, +/// since a printable value would happen to survive the lossy path and mask the +/// bug. Docker: same as `mysql_exec_returns_affected_row_count` above. +#[test] +#[ignore] +fn mysql_varbinary_round_trip() { + let out = compile_and_run(&my_program( + r#" +$db->exec("DROP TABLE IF EXISTS my_varbinary_col"); +$db->exec("CREATE TABLE my_varbinary_col (b VARBINARY(16))"); +$db->exec("INSERT INTO my_varbinary_col VALUES (x'C3FF00')"); +$val = $db->query("SELECT b FROM my_varbinary_col")->fetchColumn(); +echo bin2hex($val); +$db->exec("DROP TABLE my_varbinary_col"); +"#, + )); + assert_eq!(out, "c3ff00"); +} + +/// P0-C: `CALL`ing a stored procedure through a prepared statement must return +/// its rows. Regression for `my.rs::MyStmt::execute()` gating row +/// materialization on the PREPARE-time column count (`self.col_kinds`): +/// `COM_STMT_PREPARE` reports zero columns for `CALL proc()` (the result shape +/// is only known once the procedure actually runs), so the old code silently +/// dropped the procedure's rows off the wire. Docker: same server as above, +/// e.g.: +/// docker run -d --name my -e MARIADB_ROOT_PASSWORD=rootpw \ +/// -e MARIADB_DATABASE=testdb -e MARIADB_USER=test \ +/// -e MARIADB_PASSWORD=test -p 33060:3306 mariadb:11 +/// ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test' \ +/// cargo test --test codegen_tests -- --ignored mysql_call_stored_procedure_returns_rows +#[test] +#[ignore] +fn mysql_call_stored_procedure_returns_rows() { + let out = compile_and_run(&my_program( + r#" +$db->exec("DROP TABLE IF EXISTS my_call_src"); +$db->exec("CREATE TABLE my_call_src (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO my_call_src VALUES (1, 'a'), (2, 'b')"); +$db->exec("DROP PROCEDURE IF EXISTS my_call_sp"); +$db->exec("CREATE PROCEDURE my_call_sp() BEGIN SELECT id, name FROM my_call_src ORDER BY id; END"); +$stmt = $db->prepare("CALL my_call_sp()"); +$stmt->execute(); +$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); +echo count($rows) . ":" . $rows[0]["id"] . "=" . $rows[0]["name"] . ";" . $rows[1]["id"] . "=" . $rows[1]["name"]; +$db->exec("DROP PROCEDURE my_call_sp"); +$db->exec("DROP TABLE my_call_src"); +"#, + )); + assert_eq!(out, "2:1=a;2=b"); +} + +/// P1-f (SECURITY): under the `NO_BACKSLASH_ESCAPES` `sql_mode`, backslash is a +/// literal character inside a MySQL string literal, so `PDO::quote()`'s usual +/// backslash-escaping is unsafe there — an escaped quote (`\'`) does not +/// actually escape and lets a crafted string break out of the literal. mysqlnd +/// itself switches to quote-doubling-only in that mode; `elephc_pdo_no_backslash_escapes` +/// (bridge v21) mirrors that via a live `sql_mode` read, so `quote()` must +/// return the doubled-quote form (`'O''Brien'`), not the backslash form. Docker: +/// same server as the other MySQL fixtures in this file, e.g.: +/// docker run -d --name my -e MARIADB_ROOT_PASSWORD=rootpw \ +/// -e MARIADB_DATABASE=testdb -e MARIADB_USER=test \ +/// -e MARIADB_PASSWORD=test -p 33060:3306 mariadb:11 +/// ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test' \ +/// cargo test --test codegen_tests -- --ignored mysql_quote_no_backslash_escapes_mode +#[test] +#[ignore] +fn mysql_quote_no_backslash_escapes_mode() { + let out = compile_and_run(&my_program( + r#" +$db->exec("SET SESSION sql_mode='NO_BACKSLASH_ESCAPES'"); +echo $db->quote("O'Brien"); +"#, + )); + assert_eq!(out, "'O''Brien'"); +} + +/// Sibling of `mysql_quote_no_backslash_escapes_mode`: the connection's default +/// session mode (no `NO_BACKSLASH_ESCAPES`) keeps `PDO::quote()`'s +/// backslash-escaped form, proving the branch genuinely depends on the live +/// `elephc_pdo_no_backslash_escapes` read rather than one path always winning. +/// Docker: same server as above (see `mysql_quote_no_backslash_escapes_mode`). +#[test] +#[ignore] +fn mysql_quote_normal_mode_backslash_escapes() { + let out = compile_and_run(&my_program( + r#" +echo $db->quote("O'Brien"); +"#, + )); + assert_eq!(out, "'O\\'Brien'"); +} + +/// P1-e: `PDO::quote($string, PDO::PARAM_LOB)` on the mysql driver prefixes the +/// escaped literal with the `_binary` charset introducer, mirroring php-src's +/// `mysql_handle_quoter`, so a binary/LOB value is not reinterpreted under the +/// connection's charset. Docker: same server as above (see +/// `mysql_quote_no_backslash_escapes_mode`). +#[test] +#[ignore] +fn mysql_quote_param_lob_binary_prefix() { + let out = compile_and_run(&my_program( + r#" +echo $db->quote("ab", PDO::PARAM_LOB); +"#, + )); + assert_eq!(out, "_binary'ab'"); +} + +/// F-MY-05 (P0-C follow-up): a `CALL` behind a LEADING COMMENT is still a `CALL`. +/// This is the single highest-value live fixture of the wave: it pins the +/// data-loss half of the P0-C regression rather than its detection half. +/// `my.rs::sql_is_call_statement()` used to test only past leading WHITESPACE, so +/// `/* hint */ CALL p()` (an optimizer-hint prefix, which real applications and +/// ORMs emit routinely) and `-- note\nCALL p()` were classified as ordinary +/// statements. A non-`CALL` statement's row materialization is gated on the +/// PREPARE-time column count, and `COM_STMT_PREPARE` reports ZERO columns for a +/// `CALL` (the result shape only exists once the procedure runs) — so the +/// procedure's rows were silently dropped off the wire and `fetchAll()` returned +/// an empty set with NO error. The fix skips `/* … */`, `-- …` and `# …` runs +/// ahead of the keyword. `$db->query()` routes through `prepare()` + `execute()` +/// in the prelude, so it exercises exactly that path. +/// +/// Both comment spellings are asserted (block and line), and the row is read +/// through a `count() > 0` guard so a REGRESSION reports a readable `0-:0-` +/// rather than crashing on an out-of-range index. Docker: same server as +/// `mysql_call_stored_procedure_returns_rows` above, e.g.: +/// ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test' \ +/// cargo test --test codegen_tests -- --ignored mysql_call_behind_leading_comment_returns_rows +#[test] +#[ignore] +fn mysql_call_behind_leading_comment_returns_rows() { + let out = compile_and_run(&my_program( + r#" +$db->exec("DROP TABLE IF EXISTS my_call_cmt_src"); +$db->exec("CREATE TABLE my_call_cmt_src (id INTEGER, name TEXT)"); +$db->exec("INSERT INTO my_call_cmt_src VALUES (1, 'a')"); +$db->exec("DROP PROCEDURE IF EXISTS my_call_cmt_sp"); +$db->exec("CREATE PROCEDURE my_call_cmt_sp() BEGIN SELECT id, name FROM my_call_cmt_src; END"); +$blockRows = $db->query("/* hint */ CALL my_call_cmt_sp()")->fetchAll(PDO::FETCH_ASSOC); +$lineRows = $db->query("-- note\nCALL my_call_cmt_sp()")->fetchAll(PDO::FETCH_ASSOC); +$block = (count($blockRows) > 0) ? $blockRows[0]["name"] : "-"; +$line = (count($lineRows) > 0) ? $lineRows[0]["name"] : "-"; +echo count($blockRows) . $block . ":" . count($lineRows) . $line; +$db->exec("DROP PROCEDURE my_call_cmt_sp"); +$db->exec("DROP TABLE my_call_cmt_src"); +"#, + )); + assert_eq!(out, "1a:1a"); +} + +/// F-CORE-02: on the MYSQL driver the CONSTRUCTOR's `$username`/`$password` WIN +/// over a `user=`/`password=` the DSN already carries. php-src's handle factory +/// consults the DSN key only as a fallback for an ABSENT constructor argument +/// (`if (!dbh->username && vars[5].optval) …`, `mysql_driver.c:948-953`), the +/// opposite of pgsql's last-wins conninfo (`pgsql_driver.c:1377-1378`) — and this +/// prelude used to apply the pgsql rule to both, so +/// `new PDO("mysql:host=h;user=readonly", "admin", $pw)` connected as `readonly`: +/// a SILENT PRIVILEGE SWAP. +/// +/// Runnable against the standard test container without knowing its credentials: +/// the real ones are lifted out of `ELEPHC_MY_DSN` itself, a bogus pair is +/// APPENDED to that DSN (the bridge's `build_opts` parser is last-wins, so the +/// appended pair overrides the env DSN's own), and they are then handed back as +/// the constructor arguments. The first `new PDO($bogus)` — no constructor +/// arguments at all — is the NEGATIVE CONTROL: it must be REJECTED by the server, +/// proving the bogus credentials really are bogus and that the second connect +/// succeeds because the constructor arguments displaced them, not because the +/// server would have let anyone in. Docker: same server as above, e.g.: +/// ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test' \ +/// cargo test --test codegen_tests -- --ignored mysql_ctor_credentials_override_dsn_credentials +#[test] +#[ignore] +fn mysql_ctor_credentials_override_dsn_credentials() { + let out = compile_and_run( + r#"query("SELECT 1")->fetchColumn(); +} +"#, + ); + assert_eq!(out, "rejected:1"); +} + +/// Contrast half of `mysql_ctor_credentials_override_dsn_credentials`, pinning the +/// behavior that was already correct and that the F-CORE-02 fix must not break: a +/// DSN-ONLY credential set still connects. The constructor arguments are passed +/// EXPLICITLY as `null` here (rather than omitted) because that is the exact +/// condition the fix's append is gated on — `$username !== null` — so a future +/// change that appended an empty `;user=` for a null argument would clobber the +/// DSN's own `user=` (the parser is last-wins) and fail here, not silently in +/// production. Docker: same server as above. +#[test] +#[ignore] +fn mysql_dsn_only_credentials_still_connect() { + let out = compile_and_run( + r#"query("SELECT 1")->fetchColumn(); +"#, + ); + assert_eq!(out, "1"); +} + +/// F-MY-06: `Pdo\Mysql::ATTR_FOUND_ROWS` switches what the server reports as an +/// UPDATE's affected-row count — and therefore what `PDOStatement::rowCount()` +/// returns — from "rows actually CHANGED" to "rows MATCHED by the WHERE clause". +/// The attribute ORs `CLIENT_FOUND_ROWS` into the HANDSHAKE capability flags +/// (php-src `mysql_driver.c:776-778`), so it is a per-CONNECTION property that +/// must be known before authentication: it cannot be set after the fact, and the +/// only observable difference is on an UPDATE that matches a row but changes +/// nothing. +/// +/// The fixture therefore UPDATEs a row TO ITS OWN CURRENT VALUE (`n = 5` where it +/// already is 5) over two connections to the same server: the plain one reports +/// 0 (nothing changed), the `ATTR_FOUND_ROWS` one reports 1 (one row matched). +/// The plain connection doubles as the fixture's setup/teardown connection. +/// Docker: same server as above, e.g.: +/// ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test' \ +/// cargo test --test codegen_tests -- --ignored mysql_attr_found_rows_reports_matched_rows +#[test] +#[ignore] +fn mysql_attr_found_rows_reports_matched_rows() { + let out = compile_and_run( + r#"exec("DROP TABLE IF EXISTS my_found_rows"); +$plain->exec("CREATE TABLE my_found_rows (id INTEGER PRIMARY KEY, n INTEGER)"); +$plain->exec("INSERT INTO my_found_rows (id, n) VALUES (1, 5)"); + +$changedStmt = $plain->prepare("UPDATE my_found_rows SET n = 5 WHERE id = 1"); +$changedStmt->execute(); +$changed = $changedStmt->rowCount(); + +$found = new \Pdo\Mysql($dsn, null, null, [\Pdo\Mysql::ATTR_FOUND_ROWS => true]); +$matchedStmt = $found->prepare("UPDATE my_found_rows SET n = 5 WHERE id = 1"); +$matchedStmt->execute(); +$matched = $matchedStmt->rowCount(); + +$plain->exec("DROP TABLE my_found_rows"); +echo $changed . ":" . $matched; +"#, + ); + assert_eq!(out, "0:1"); +} + +/// F-CORE-10: the DEFAULT connect timeout. `my.rs::build_opts()` now applies +/// `DEFAULT_CONNECT_TIMEOUT_SECS` (30 s, php-src's own pdo_mysql default) +/// UNCONDITIONALLY, so a DSN naming neither a `connect_timeout` key nor (through +/// the prelude) `PDO::ATTR_TIMEOUT` no longer waits out the OS's TCP connect +/// timeout — roughly 130 s on Linux (`tcp_syn_retries=6`) and ~75 s on macOS. +/// Sibling of `test_mysql_attr_timeout_fails_fast`, which pins the EXPLICIT +/// attribute; this one deliberately passes NO options at all, which is the +/// configuration that used to hang. +/// +/// Uses a non-routable TEST-NET-1 address (RFC 5737, `192.0.2.0/24`) so the SYN +/// blackholes rather than drawing an immediate "connection refused" — the same +/// address family the sibling test relies on. Needs no live server: the point is +/// that the connection NEVER completes. +/// +/// The compile+run is driven from a worker thread behind a `recv_timeout` so a +/// regression FAILS this test rather than parking the whole suite on that OS +/// timeout (the in-PHP `< 35.0` assertion alone cannot bound a hang). The +/// warm-up run first pays for any lazy `cargo build -p elephc-pdo` of the bridge +/// staticlib and the cached SDK/runtime-object lookups OFF the clock, so the +/// guarded run is only ever a small compile plus the connect attempt itself, and +/// the 120 s budget has no legitimate way to be reached. +#[test] +#[ignore] +fn mysql_default_connect_timeout_bounds_blackholed_connect() { + use std::sync::mpsc; + use std::time::Duration; + + // Warm-up: an in-process SQLite PDO program links the very same bridge + // staticlib, so it forces every lazy build/OnceLock the guarded run would + // otherwise be billed for. No server needed. + let warm = compile_and_run( + r#" assert_eq!(out, "fast"), + Err(mpsc::RecvTimeoutError::Timeout) => panic!( + "connecting to a blackholed address with no ATTR_TIMEOUT was still running after \ + 120 s: build_opts() is no longer applying the default connect timeout" + ), + Err(mpsc::RecvTimeoutError::Disconnected) => { + panic!("the compile/run worker panicked; its output is above") + } + } +} + +/// F-CORE-16: the persistent pool is keyed on the (DSN, ATTR_PERSISTENT key) PAIR, +/// not on the DSN alone. php-src builds the persistent hashkey from both +/// (`pdo_dbh.c:389-404`): an `ATTR_PERSISTENT` that is a non-numeric, non-empty +/// STRING is a user-supplied POOL KEY, and separating one DSN into several +/// independent pooled connections is the entire point of that named form. Keying +/// on the DSN alone silently collapsed them onto ONE shared server session. +/// +/// `SELECT CONNECTION_ID()` is the observation: it is the server's own id for the +/// session, so two handles that are really one connection cannot disagree on it. +/// The third open REUSES the first key and is the NECESSARY CONTROL — without it +/// a "distinct" result would also be produced by persistence being broken outright +/// (every open making a fresh connection), which is the opposite bug. Sharing one +/// pooled handle between two live PDO objects is safe: `elephc_pdo_close()` +/// no-ops on a persistent id (`lib.rs:600-604`), so neither destructor closes the +/// session out from under the other. Docker: same server as above, e.g.: +/// ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test' \ +/// cargo test --test codegen_tests -- --ignored mysql_persistent_key_separates_pooled_connections +#[test] +#[ignore] +fn mysql_persistent_key_separates_pooled_connections() { + let out = compile_and_run( + r#" "elephc_key_a"]); +$b = new PDO($dsn, null, null, [PDO::ATTR_PERSISTENT => "elephc_key_b"]); +$again = new PDO($dsn, null, null, [PDO::ATTR_PERSISTENT => "elephc_key_a"]); +$idA = (string) $a->query("SELECT CONNECTION_ID()")->fetchColumn(); +$idB = (string) $b->query("SELECT CONNECTION_ID()")->fetchColumn(); +$idAgain = (string) $again->query("SELECT CONNECTION_ID()")->fetchColumn(); +echo (($idA !== $idB) ? "distinct" : "same") . ":" . (($idA === $idAgain) ? "reuse" : "new"); +"#, + ); + assert_eq!(out, "distinct:reuse"); +} + +/// F-MY-08 / v43: `getColumnMeta()` on a `mysql:` statement reports MySQL's +/// wire type, PDO parameter type, source table, declared size/precision, and native +/// column flags rather than the SQLite storage-class vocabulary +/// ("integer"/"double"/"string") the prelude used to hand every driver. php-src +/// builds the key from `type_to_name_native()`, whose `PDO_MYSQL_NATIVE_TYPE_NAME` +/// macro simply stringifies the `MYSQL_TYPE_` suffix — so an `INT` is `LONG`, a +/// `VARCHAR` is `VAR_STRING`, a `DECIMAL` is `NEWDECIMAL`, a `BLOB`/`TEXT` is +/// `BLOB`, a `BIGINT` is `LONGLONG`, a `DATETIME` is `DATETIME`. That vocabulary is +/// the whole point of the key: the storage class cannot tell a `VARCHAR` from a +/// `BLOB` from a `NEWDECIMAL` (MySQL hands all three over as strings), which is +/// exactly what a caller reading `native_type` is asking about. +/// +/// The six expectations below were cross-read against `my.rs::native_type_name()`, +/// the mapping actually implemented, and against php-src's switch: every arm agrees +/// (php-src's list is STRING, VAR_STRING, TINY, SHORT, LONG, LONGLONG, INT24, FLOAT, +/// DOUBLE, DECIMAL, NEWDECIMAL, GEOMETRY, TIMESTAMP, YEAR, SET, ENUM, DATE, NEWDATE, +/// TIME, DATETIME, TINY_BLOB, MEDIUM_BLOB, LONG_BLOB, BLOB, NULL, BIT, JSON, and a +/// `default:` that OMITS the key; `native_type_name` spells the same 27 names and +/// returns `""` — the bridge's "no metadata" value — for the default). No name +/// disagrees, so there is nothing to flag here. +/// +/// The second half re-reads the SAME columns through a statement whose result set is +/// EMPTY (`WHERE 1 = 0`). It must report the identical names: `column_native_type` +/// reads the PREPARE-time column descriptor, never a live cell, so the DECLARED type +/// survives a result set with no row to inspect — the property the storage-class +/// derivation (which would report "null" for every column here) structurally cannot +/// have. `JSON` is deliberately absent from the fixture: MariaDB aliases it to +/// `LONGTEXT` and would report `BLOB`, pinning the server's alias rather than the +/// mapping. Docker: same server as the other MySQL fixtures, e.g.: +/// ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test' \ +/// cargo test --test codegen_tests -- --ignored mysql_get_column_meta_native_types +#[test] +#[ignore] +fn mysql_get_column_meta_native_types() { + let out = compile_and_run(&my_program( + r#" +$db->exec("DROP TABLE IF EXISTS my_native_meta"); +$db->exec("CREATE TABLE my_native_meta (i INT NOT NULL PRIMARY KEY, v VARCHAR(20) UNIQUE, m DECIMAL(10,2), b BLOB, big BIGINT, ts DATETIME)"); +$db->exec("INSERT INTO my_native_meta VALUES (42, 'ada', '1234.50', 'bin', 9000000000, '2024-01-15 10:30:00')"); + +$rowed = $db->query("SELECT i, v, m, b, big, ts FROM my_native_meta"); +$withRow = []; +for ($c = 0; $c < 6; $c++) { + $meta = $rowed->getColumnMeta($c); + $withRow[] = (string) $meta["native_type"] . ":" . $meta["pdo_type"]; +} +$mi = $rowed->getColumnMeta(0); +$mv = $rowed->getColumnMeta(1); +$mm = $rowed->getColumnMeta(2); +$mb = $rowed->getColumnMeta(3); + +$empty = $db->query("SELECT i, v, m, b, big, ts FROM my_native_meta WHERE 1 = 0"); +$noRow = []; +for ($c = 0; $c < 6; $c++) { + $meta = $empty->getColumnMeta($c); + $noRow[] = (string) $meta["native_type"]; +} + +echo implode(",", $withRow) . "|" . implode(",", $noRow) + . "|" . $mi["table"] . ":" . implode(",", $mi["flags"]) + . "|" . implode(",", $mv["flags"]) + . "|" . implode(",", $mb["flags"]) + . "|" . (($mv["len"] >= 20) ? "len-y" : "len-n") . ":" . $mm["precision"]; +$db->exec("DROP TABLE my_native_meta"); +"#, + )); + assert_eq!( + out, + "LONG:1,VAR_STRING:2,NEWDECIMAL:2,BLOB:2,LONGLONG:1,DATETIME:2|\ + LONG,VAR_STRING,NEWDECIMAL,BLOB,LONGLONG,DATETIME|\ + my_native_meta:not_null,primary_key|unique_key|blob|len-y:2" + ); +} + +/// F-MY-03: under the `NO_BACKSLASH_ESCAPES` `sql_mode`, backslash is an ORDINARY +/// BYTE inside a MySQL string literal — doubling is the only escape left — so the +/// placeholder scanner has to stop assuming backslash-escaping there, or it +/// disagrees with the SERVER about where a literal ENDS and therefore about how many +/// placeholders the statement has. +/// +/// A string literal ending in a BACKSLASH, with a placeholder just past it, is the +/// minimal statement that exposes it — `CONCAT('a\', txt) … WHERE txt = ?`. In this +/// mode the server closes the literal at the quote right after the backslash (its +/// value being the two bytes `a\`) and sees ONE placeholder. The old scanner read the +/// `\'` as an ESCAPED QUOTE, ran off the end of the SQL looking for a close that was +/// never coming, and swallowed the `?` as string content — so `translate_placeholders` +/// allocated ZERO slots while the server's own prepare of that same text reported one, +/// and the `execute()` below bound into a slot map with no slot 1. (A backslash in the +/// MIDDLE of a literal, `'C:\path'`, is NOT a witness: the old scanner consumed the `p` +/// and still found the real closing quote, so both modes agree. Only a TRAILING +/// backslash moves the literal's end.) `my.rs::MyConn::prepare()` now threads the +/// connection's LIVE `no_backslash_escape()` session state into the scan — the only +/// place that flag can be read, `translate_placeholders` being a free function with no +/// connection. +/// +/// The literal is round-tripped through `CONCAT` against a real column, so the bound +/// parameter sits in a `WHERE` like every other fixture here and the result column is a +/// genuine string expression, not a bare `?` whose PREPARE-time type the server has not +/// yet inferred. The value is asserted through `bin2hex()` (`615c78` = `a\x`), not as a +/// printable string: the point is that the byte after `a` really is the backslash the +/// server kept, so a regression that dropped it or re-escaped it cannot pass. Both +/// placeholder spellings are driven, since `:name` and `?` share the string-literal scan +/// but not the slot bookkeeping. Docker: same server as above, e.g.: +/// ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test' \ +/// cargo test --test codegen_tests -- --ignored mysql_no_backslash_escapes_placeholder_scan +#[test] +#[ignore] +fn mysql_no_backslash_escapes_placeholder_scan() { + let out = compile_and_run(&my_program( + r#" +$db->exec("SET SESSION sql_mode='NO_BACKSLASH_ESCAPES'"); +$db->exec("DROP TABLE IF EXISTS my_nbe"); +$db->exec("CREATE TABLE my_nbe (txt VARCHAR(32))"); +$db->exec("INSERT INTO my_nbe VALUES ('x'), ('y')"); + +// PHP "\\" is ONE backslash, so the SQL really is: +// SELECT CONCAT('a\', txt) AS lit FROM my_nbe WHERE txt = ? +$pos = $db->prepare("SELECT CONCAT('a\\', txt) AS lit FROM my_nbe WHERE txt = ?"); +$pos->execute(["x"]); +$posLit = $pos->fetchColumn(); + +$named = $db->prepare("SELECT CONCAT('a\\', txt) AS lit FROM my_nbe WHERE txt = :v"); +$named->execute([":v" => "y"]); +$namedLit = $named->fetchColumn(); + +echo bin2hex($posLit) . ":" . bin2hex($namedLit); +$db->exec("DROP TABLE my_nbe"); +"#, + )); + assert_eq!(out, "615c78:615c79"); +} + +/// F-STMT-15 on a NON-SQLITE driver: `FETCH_GROUP` and `FETCH_UNIQUE` (which used to +/// throw "not yet supported") reshape a live MySQL result set around a key taken from +/// COLUMN 0, which both modes CONSUME — the key is excluded from the row, and the row +/// is built from columns 1..n-1. `FETCH_GROUP` maps each key to a LIST of every row +/// that carried it, in result order; `FETCH_UNIQUE` maps it to ONE row, LAST WRITE +/// WINS (php-src overwrites with `zend_symtable_update` and never complains about the +/// duplicate). This is the driver-independence proof for the prelude's new +/// `fetchAllGrouped()`: it reads rows through `stepCursor()`/`columnValue()` like every +/// other fetch path, so nothing about it is SQLite-specific, and this fixture is what +/// says so rather than assuming it. +/// +/// Four shapes, all on `('fruit','apple'), ('fruit','banana'), ('veg','carrot')`: +/// - `GROUP|COLUMN` with NO explicit index is the classic `[kind => [name, …]]` idiom. +/// It only works because php-src defaults the VALUE column to 1 when GROUP is set +/// (column 0 is already the key, so defaulting it to 0 would return the key again); +/// a regression there yields `[fruit => [fruit, fruit]]`, which this pins. +/// - `GROUP|ASSOC` maps to a list of column-name rows with the key column absent. +/// - `GROUP|NUM` proves the RE-INDEXING: the first column AFTER the key lands at [0], +/// not at its original offset [1] (php-src walks the row with a separate output +/// cursor). A row that kept its offsets would have no [0] at all. +/// - `UNIQUE|ASSOC` proves last-wins: 'fruit' appears twice, and the surviving row is +/// 'banana', the LAST one. +/// +/// Every key here is NON-NUMERIC on purpose. The one documented divergence in +/// `fetchAllGrouped()` is that elephc's array keeps an integer-LOOKING group key a +/// STRING key where PHP folds it back to an int; using kind names keeps this fixture +/// about PDO's grouping semantics instead of about that array-semantics gap. Docker: +/// same server as above, e.g.: +/// ELEPHC_MY_DSN='mysql:host=127.0.0.1;port=33060;dbname=testdb;user=test;password=test' \ +/// cargo test --test codegen_tests -- --ignored mysql_fetch_all_group_and_unique +#[test] +#[ignore] +fn mysql_fetch_all_group_and_unique() { + let out = compile_and_run(&my_program( + r#" +$db->exec("DROP TABLE IF EXISTS my_group"); +$db->exec("CREATE TABLE my_group (kind VARCHAR(16), name VARCHAR(16), n INTEGER)"); +$db->exec("INSERT INTO my_group VALUES ('fruit', 'apple', 1), ('fruit', 'banana', 2), ('veg', 'carrot', 3)"); + +$byCol = $db->query("SELECT kind, name FROM my_group ORDER BY n")->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_COLUMN); +$col = count($byCol["fruit"]) . ":" . $byCol["fruit"][0] . "," . $byCol["fruit"][1] . "/" . $byCol["veg"][0]; + +$byAssoc = $db->query("SELECT kind, name, n FROM my_group ORDER BY n")->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_ASSOC); +$assoc = count($byAssoc["fruit"]) . ":" . $byAssoc["fruit"][1]["name"] . "=" . $byAssoc["fruit"][1]["n"]; + +$byNum = $db->query("SELECT kind, name, n FROM my_group ORDER BY n")->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_NUM); +$num = $byNum["veg"][0][0] . "=" . $byNum["veg"][0][1]; + +$uniq = $db->query("SELECT kind, name FROM my_group ORDER BY n")->fetchAll(PDO::FETCH_UNIQUE | PDO::FETCH_ASSOC); +$last = $uniq["fruit"]["name"] . "/" . $uniq["veg"]["name"]; + +echo $col . "|" . $assoc . "|" . $num . "|" . $last; +$db->exec("DROP TABLE my_group"); +"#, + )); + assert_eq!(out, "2:apple,banana/carrot|2:banana=2|carrot=3|banana/carrot"); +} + +/// MySQL's two generic driver hooks are live rather than stored echo values: +/// AUTOCOMMIT reaches the server session, and DEFAULT_STR_PARAM controls the +/// national-string marker used by emulated prepared statements. +#[test] +#[ignore] +fn mysql_autocommit_and_default_string_parameter_attributes() { + let out = compile_and_run(&my_program( + r#" +echo $db->getAttribute(PDO::ATTR_AUTOCOMMIT) ? "1" : "0"; +echo $db->setAttribute(PDO::ATTR_AUTOCOMMIT, false) ? "1" : "0"; +echo $db->getAttribute(PDO::ATTR_AUTOCOMMIT) ? "1" : "0"; +echo $db->setAttribute(PDO::ATTR_AUTOCOMMIT, true) ? "1" : "0"; +echo "|"; +echo $db->setAttribute(PDO::ATTR_DEFAULT_STR_PARAM, PDO::PARAM_STR_NATL) ? "1" : "0"; +echo ($db->getAttribute(PDO::ATTR_DEFAULT_STR_PARAM) === PDO::PARAM_STR_NATL) ? "N" : "C"; +$stmt = $db->prepare("SELECT ?"); +$stmt->execute(["café"]); +echo $stmt->fetchColumn(); +echo $db->setAttribute(PDO::ATTR_DEFAULT_STR_PARAM, PDO::PARAM_STR_CHAR) ? "1" : "0"; +echo ($db->getAttribute(PDO::ATTR_DEFAULT_STR_PARAM) === PDO::PARAM_STR_CHAR) ? "C" : "N"; +"#, + )); + assert_eq!(out, "1101|1Ncafé1C"); +} + +/// Verifies emulated MySQL multi-statements retain every wire result set and +/// `nextRowset()` refreshes the active rows/metadata until it returns false. +#[test] +#[ignore] +fn mysql_next_rowset_traverses_multi_statement_results() { + let out = compile_and_run(&my_program( + r#" +$stmt = $db->query("SELECT 1 AS value; SELECT 2 AS value; SELECT 3 AS value"); +echo $stmt->fetchColumn() . ":" . $stmt->columnCount() . "|"; +echo ($stmt->nextRowset() ? "next" : "done") . ":" . $stmt->fetchColumn() . "|"; +echo ($stmt->nextRowset() ? "next" : "done") . ":" . $stmt->fetchColumn() . "|"; +echo $stmt->nextRowset() ? "next" : "done"; +"#, + )); + assert_eq!(out, "1:1|next:2|next:3|done"); +} diff --git a/tests/codegen/pdo_oci.rs b/tests/codegen/pdo_oci.rs new file mode 100644 index 0000000000..af09c8394b --- /dev/null +++ b/tests/codegen/pdo_oci.rs @@ -0,0 +1,93 @@ +//! Purpose: +//! End-to-end surface and live-server tests for the optional PDO_OCI backend. +//! +//! Called from: +//! - `cargo test --features pdo-oci --test codegen_tests`. +//! +//! Key details: +//! - Surface tests do not load Oracle Instant Client or contact a database. +//! - The ignored live test reads `ELEPHC_OCI_DSN` and exercises the real OCI client. + +use crate::support::*; +use elephc::php_version::PhpVersion; + +/// Exposes PDO_OCI's registry entry and unchanged legacy constants on PHP 8.4+. +#[test] +fn test_pdo_oci_surface_php84() { + let out = compile_and_run_with_php_version( + r#" PDO::ERRMODE_EXCEPTION, PDO::ATTR_PREFETCH => 7]); +echo $db->getAttribute(PDO::ATTR_DRIVER_NAME) . "|"; +echo (($db->getAttribute(PDO::ATTR_SERVER_VERSION) !== "" && $db->getAttribute(PDO::ATTR_CLIENT_VERSION) !== "") ? "versions" : "missing") . "|"; +echo $db->getAttribute(PDO::ATTR_PREFETCH) . "|"; +$db->setAttribute(PDO::OCI_ATTR_MODULE, "elephc-pdo"); +$db->setAttribute(PDO::OCI_ATTR_ACTION, "live-test"); +$db->exec("BEGIN EXECUTE IMMEDIATE 'DROP TABLE ELEPHC_PDO_OCI_TEST'; EXCEPTION WHEN OTHERS THEN NULL; END;"); +$db->exec("CREATE TABLE ELEPHC_PDO_OCI_TEST (ID NUMBER NOT NULL, NAME VARCHAR2(80), DATA BLOB)"); +$insert = $db->prepare("INSERT INTO ELEPHC_PDO_OCI_TEST (ID, NAME, DATA) VALUES (:id, :name, :data)"); +$insert->bindValue(1, 7, PDO::PARAM_INT); +$insert->bindValue(2, "Éléphant", PDO::PARAM_STR); +$insert->bindValue(3, "A\0B", PDO::PARAM_LOB); +$insert->execute(); +echo $insert->rowCount() . "|"; +$io = $db->prepare("BEGIN :p := :p + 100; END;"); +$p = -1; +$io->bindParam(":p", $p, PDO::PARAM_INT | PDO::PARAM_INPUT_OUTPUT, 10); +$io->execute(); +echo gettype($p) . ":" . $p . "|"; +$lobStmt = $db->prepare("BEGIN SELECT DATA INTO :data FROM ELEPHC_PDO_OCI_TEST WHERE ID = 7; END;"); +$lob = null; +$lobStmt->bindParam(":data", $lob, PDO::PARAM_LOB); +$lobStmt->execute(); +echo (is_resource($lob) ? stream_get_contents($lob) : "not-stream") . "|"; +$select = $db->prepare("SELECT ID, NAME, DATA FROM ELEPHC_PDO_OCI_TEST ORDER BY ID", [PDO::ATTR_PREFETCH => 3, PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]); +$select->execute(); +$row = $select->fetch(PDO::FETCH_ASSOC, PDO::FETCH_ORI_FIRST); +echo gettype($row["ID"]) . ":" . $row["ID"] . ":" . $row["NAME"] . ":" . stream_get_contents($row["DATA"]) . "|"; +$meta = $select->getColumnMeta(0); +echo $meta["native_type"] . ":" . $meta["pdo_type"] . ":" . implode(",", $meta["flags"]) . "|"; +$db->beginTransaction(); +$db->exec("INSERT INTO ELEPHC_PDO_OCI_TEST (ID, NAME, DATA) VALUES (8, 'rollback', empty_blob())"); +$db->rollBack(); +echo $db->query("SELECT COUNT(*) FROM ELEPHC_PDO_OCI_TEST")->fetchColumn() . "|"; +try {{ $db->query("SELECT * FROM ELEPHC_PDO_OCI_MISSING"); }} catch (PDOException $error) {{ echo $error->errorInfo[0] . ":" . (($error->errorInfo[1] !== 0) ? "native" : "zero"); }} +$db->exec("DROP TABLE ELEPHC_PDO_OCI_TEST"); +"# + ); + let out = compile_and_run(&source); + assert_eq!( + out, + "oci|versions|7|1|string:99|A\0B|string:7:Éléphant:A\0B|NUMBER:2:not_null|1|HY000:native" + ); +} diff --git a/tests/codegen/pdo_odbc.rs b/tests/codegen/pdo_odbc.rs new file mode 100644 index 0000000000..c458e7b981 --- /dev/null +++ b/tests/codegen/pdo_odbc.rs @@ -0,0 +1,89 @@ +//! Purpose: +//! End-to-end surface and live-server tests for the optional PDO_ODBC backend. +//! +//! Called from: +//! - `cargo test --features pdo-odbc --test codegen_tests`. +//! +//! Key details: +//! - Surface tests require unixODBC at link time but no configured database driver. +//! - The ignored live test uses a direct DSN from `ELEPHC_ODBC_DSN`. + +use crate::support::*; +use elephc::php_version::PhpVersion; + +/// Exposes PDO_ODBC's manager type, registry entry, aliases, and PHP 8.4 class. +#[test] +fn test_pdo_odbc_surface_php84() { + let out = compile_and_run_with_php_version( + r#" PDO::ERRMODE_EXCEPTION, Pdo\Odbc::ATTR_ASSUME_UTF8 => true]); +echo $db->getAttribute(PDO::ATTR_DRIVER_NAME) . "|"; +echo PDO_ODBC_TYPE . ":" . (($db->getAttribute(PDO::ATTR_SERVER_VERSION) !== "" && $db->getAttribute(PDO::ATTR_SERVER_INFO) !== "") ? "server" : "missing") . ":" . $db->getAttribute(PDO::ATTR_CLIENT_VERSION) . "|"; +echo ($db->getAttribute(Pdo\Odbc::ATTR_ASSUME_UTF8) ? "utf8" : "raw") . "|"; +try {{ $db->quote("O'Brien"); }} catch (PDOException $e) {{ echo $e->errorInfo[0]; }} +echo "|"; +$db->exec("CREATE TEMP TABLE elephc_odbc_test (id INTEGER, name VARCHAR(40))"); +$stmt = $db->prepare("INSERT INTO elephc_odbc_test (id, name) VALUES (:id, :name)"); +$stmt->execute(["id" => 7, "name" => "Éléphant"]); +echo $stmt->rowCount() . "|"; +try {{ + $db->prepare("SELECT id FROM elephc_odbc_test", [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]); +}} catch (PDOException $e) {{ + echo $e->errorInfo[0] . "|"; +}} +$stmt = $db->prepare("SELECT id, name FROM elephc_odbc_test ORDER BY id"); +$stmt->execute(); +$row = $stmt->fetch(PDO::FETCH_ASSOC); +echo gettype($row["id"]) . ":" . $row["id"] . ":" . $row["name"] . "|"; +$meta = $stmt->getColumnMeta(0); +echo (is_array($meta) ? count($meta) : 0) . ":" . $meta["pdo_type"] . "|"; +$db->beginTransaction(); +$db->exec("INSERT INTO elephc_odbc_test (id, name) VALUES (8, 'rollback')"); +$db->rollBack(); +echo $db->query("SELECT COUNT(*) FROM elephc_odbc_test")->fetchColumn() . "|"; +$sets = $db->query("SELECT 1 AS value; SELECT 2 AS value"); +echo $sets->fetchColumn() . ":" . ($sets->nextRowset() ? $sets->fetchColumn() : "missing") . "|"; +try {{ $db->query("SELECT * FROM elephc_missing_odbc_table"); }} catch (PDOException $e) {{ echo $e->errorInfo[0] . ":" . (($e->errorInfo[1] !== 0) ? "native" : "zero"); }} +}} catch (Throwable $fatal) {{ +echo "FAIL:" . $fatal->getMessage(); +}} +"# + ); + let out = compile_and_run(&source); + assert_eq!(out, "odbc|unixODBC:server:ODBC-unixODBC|utf8|IM001|1|HYC00|string:7:Éléphant|1:2|1|1:2|42P01:native"); +} diff --git a/tests/codegen/pdo_pgsql.rs b/tests/codegen/pdo_pgsql.rs index 49ee1a4126..fcdbfe1613 100644 --- a/tests/codegen/pdo_pgsql.rs +++ b/tests/codegen/pdo_pgsql.rs @@ -12,15 +12,116 @@ //! -e POSTGRES_DB=testdb -p 55432:5432 postgres:16-alpine //! ELEPHC_PG_DSN='pgsql:host=localhost;port=55432;dbname=testdb;user=test;password=test' \ //! cargo test --test codegen_tests -- --ignored pgsql +//! The focused GSSAPI fixtures provision their own KDC and server through +//! `scripts/test-pdo-gss.sh` and require the `libpq-gss` bridge profile. //! //! Key details: //! - Each fixture opens its connection from `getenv("ELEPHC_PG_DSN")` and uses //! `DROP TABLE IF EXISTS` on a fixture-specific table so reruns are idempotent. //! - The same prelude drives both drivers; these tests exercise the PostgreSQL -//! specifics: `$1`-placeholder translation, `SERIAL`/`lastInsertId`, and -//! bool/float/null type decoding. +//! specifics: `$1`-placeholder translation (including the cast-run and +//! dollar-quote-tag scanner rules), `SERIAL`/`lastInsertId`, bool/float/null type +//! decoding, `PARAM_BOOL`'s real `'t'`/`'f'` bind, the full `getColumnMeta()` column +//! description (type OID, table OID, raw `PQfsize`/`PQfmod`), the `COPY` methods, and +//! the libpq `connect_timeout` default. use crate::support::*; +use elephc::php_version::PhpVersion; + +/// Reports whether the dedicated Kerberos fixture exported both required inputs, +/// failing loudly when its script marked the integration environment as mandatory. +fn gss_fixture_available() -> bool { + let available = std::env::var_os("ELEPHC_PG_GSS_DSN").is_some() + && std::env::var_os("ELEPHC_PG_GSS_EMPTY_CACHE").is_some(); + if std::env::var_os("ELEPHC_PDO_GSS_REQUIRED").is_some() { + assert!(available, "required PDO GSSAPI fixture variables are missing"); + } + available +} + +/// A valid Kerberos credential cache must complete both GSS authentication and +/// GSS transport encryption when the libpq DSN requires them explicitly. +#[test] +#[ignore] +fn test_pgsql_gssapi_requires_authentication_and_encryption() { + if !gss_fixture_available() { + return; + } + let out = compile_and_run( + r#"query("SELECT current_user")->fetchColumn(); +} catch (PDOException $error) { + echo "gss-error:" . $error->getMessage(); +} +"#, + ); + assert_eq!(out, "elephc_gss"); +} + +/// `gssencmode=require` must fail closed when the compiled program has no +/// credential cache, rather than falling back to TLS, password, or trust auth. +#[test] +#[ignore] +fn test_pgsql_gssapi_missing_credential_cache_fails_closed() { + if !gss_fixture_available() { + return; + } + let out = compile_and_run( + r#"query("SELECT current_user")->fetchColumn(); +} catch (PDOException $error) { + $message = strtolower($error->getMessage()); + echo str_contains($message, "credential") || str_contains($message, "gss") + ? "missing-cache-blocked" + : "wrong-failure:" . $message; +} +"#, + ); + assert_eq!(out, "missing-cache-blocked"); +} + +/// Verifies PHP 8.4's nullable notice callback signature without requiring a live server. +#[test] +fn test_pdo_pgsql_notice_callback_accepts_null() { + let out = compile_and_run( + r#"setNoticeCallback(null); +} +echo "ok"; +"#, + ); + assert_eq!(out, "ok"); +} + +/// Verifies every PHP 8.4 legacy pdo_pgsql method signature lowers without a live server. +#[test] +fn test_pdo_pgsql_legacy_method_signatures_compile() { + let out = compile_and_run( + r#"pgsqlCopyFromArray("items", ["1\tAda"]); + $connection->pgsqlCopyFromFile("items", "input.tsv"); + $connection->pgsqlCopyToArray("items"); + $connection->pgsqlCopyToFile("items", "output.tsv"); + $oid = $connection->pgsqlLOBCreate(); + $connection->pgsqlLOBOpen((string) $oid); + $connection->pgsqlLOBUnlink((string) $oid); + $connection->pgsqlGetNotify(PDO::FETCH_ASSOC, 0); + $connection->pgsqlGetPid(); + } +} +echo "ok"; +"#, + ); + assert_eq!(out, "ok"); +} /// Wraps a PHP body that opens `$db` from `ELEPHC_PG_DSN`, so each fixture only /// writes the database logic under test. @@ -33,6 +134,126 @@ fn pg_program(body: &str) -> String { ) } +/// Generic connection-information attributes expose the linked client, live +/// libpq-equivalent status, and PostgreSQL session parameters. +#[test] +#[ignore] +fn test_pgsql_connection_information_attributes() { + let out = compile_and_run(&pg_program( + r#" +$client = (string) $db->getAttribute(PDO::ATTR_CLIENT_VERSION); +$server = (string) $db->getAttribute(PDO::ATTR_SERVER_VERSION); +$info = (string) $db->getAttribute(PDO::ATTR_SERVER_INFO); +$status = (string) $db->getAttribute(PDO::ATTR_CONNECTION_STATUS); +echo (strpos($client, "postgres ") === 0 ? "client" : "bad-client") . "|"; +echo (strlen($server) > 0 ? "server" : "bad-server") . "|"; +echo (strpos($info, "PID: ") === 0 && strpos($info, "; Client Encoding: ") !== false ? "info" : "bad-info") . "|"; +echo ($status === "Connection OK; waiting to send." ? "status" : "bad-status"); +"#, + )); + assert_eq!(out, "client|server|info|status"); +} + +/// PostgreSQL scroll cursors honor every PDO fetch orientation, including +/// one-based/negative absolute positions and relative movement. +#[test] +#[ignore] +fn test_pgsql_scroll_cursor_orientations() { + let out = compile_and_run(&pg_program( + r#" +$stmt = $db->prepare("SELECT n FROM generate_series(1, 4) AS n", [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]); +$stmt->execute(); +$first = $stmt->fetch(PDO::FETCH_NUM, PDO::FETCH_ORI_FIRST); +$next = $stmt->fetch(PDO::FETCH_NUM, PDO::FETCH_ORI_NEXT); +$last = $stmt->fetch(PDO::FETCH_NUM, PDO::FETCH_ORI_LAST); +$prior = $stmt->fetch(PDO::FETCH_NUM, PDO::FETCH_ORI_PRIOR); +$absolute = $stmt->fetch(PDO::FETCH_NUM, PDO::FETCH_ORI_ABS, 2); +$relative = $stmt->fetch(PDO::FETCH_NUM, PDO::FETCH_ORI_REL, 1); +$back = $stmt->fetch(PDO::FETCH_NUM, PDO::FETCH_ORI_REL, -2); +$negative = $stmt->fetch(PDO::FETCH_NUM, PDO::FETCH_ORI_ABS, -1); +$before = $stmt->fetch(PDO::FETCH_NUM, PDO::FETCH_ORI_ABS, 0); +$restart = $stmt->fetch(PDO::FETCH_NUM, PDO::FETCH_ORI_NEXT); +echo $first[0] . $next[0] . $last[0] . $prior[0] . $absolute[0] + . $relative[0] . $back[0] . $negative[0] . ($before === false ? "F" : "T") . $restart[0]; +"#, + )); + assert_eq!(out, "12432314F1"); +} + +/// PostgreSQL exposes statement-owned result memory after execution, grows with +/// the result, and records HY000 while returning null before execution. +#[test] +#[ignore] +fn test_pgsql_result_memory_size_attribute() { + let out = compile_and_run(&pg_program( + r#" +$small = $db->query("SELECT 1")->getAttribute(Pdo\Pgsql::ATTR_RESULT_MEMORY_SIZE); +$large = $db->query("SELECT generate_series(1, 1000)")->getAttribute(Pdo\Pgsql::ATTR_RESULT_MEMORY_SIZE); +$pending = $db->prepare("SELECT 1"); +$none = $pending->getAttribute(Pdo\Pgsql::ATTR_RESULT_MEMORY_SIZE); +$error = $pending->errorInfo(); +echo (is_int($small) && $small > 0 ? "small" : "bad-small") . "|"; +echo (is_int($large) && $large > $small ? "large" : "bad-large") . "|"; +echo (is_null($none) ? "null" : "bad-null") . "|" . $error[0]; +"#, + )); + assert_eq!(out, "small|large|null|HY000"); +} + +/// PostgreSQL `ATTR_PREFETCH=false` selects single-row/unbuffered semantics: +/// SELECT rowCount is not known up front and starting another query closes the +/// older cursor. A prepare-local true override remains buffered. +#[test] +#[ignore] +fn test_pgsql_prefetch_connection_and_statement_modes() { + let out = compile_and_run( + r#"setAttribute(PDO::ATTR_PREFETCH, false); +$streamed = $db->prepare("SELECT generate_series(1, 3) AS n"); +$streamed->execute(); +$first = $streamed->fetchColumn(); +$rowCount = $streamed->rowCount(); +$db->query("SELECT 99")->fetchColumn(); +$closed = $streamed->fetch() === false ? "closed" : "bad"; + +$buffered = $db->prepare("SELECT generate_series(1, 2) AS n", [PDO::ATTR_PREFETCH => true]); +$buffered->execute(); +$bufferedCount = $buffered->rowCount(); +$db->query("SELECT 100")->fetchColumn(); +$stillReadable = $buffered->fetchColumn(); +echo $first . ":" . $rowCount . ":" . $closed . ":" . $bufferedCount . ":" . $stillReadable; +"#, + ); + assert_eq!(out, "1:0:closed:2:1"); +} + +/// PHP 8.5 extends ATTR_PREFETCH=0 to emulated/simple-protocol statements. Only +/// the active row may be retained even when two large rows cross the wire. +#[test] +#[ignore] +fn test_php85_pgsql_emulated_prefetch_is_demand_driven() { + let out = compile_and_run_with_php_version( + r#" true, + \PDO::ATTR_PREFETCH => false, +]); +$stmt = $db->prepare("SELECT repeat('a', 131072) AS value UNION ALL SELECT repeat('b', 131072)"); +$stmt->execute(); +$memory = $stmt->getAttribute(\Pdo\Pgsql::ATTR_RESULT_MEMORY_SIZE); +$first = $stmt->fetchColumn(); +$second = $stmt->fetchColumn(); +$stmt->closeCursor(); +echo ($memory >= 131072 && $memory < 200000 ? "streamed" : "buffered") + . ":" . strlen((string) $first) . ":" . substr((string) $first, 0, 1) + . ":" . strlen((string) $second) . ":" . substr((string) $second, 0, 1); +"#, + PhpVersion::Php85, + ); + assert_eq!(out, "streamed:131072:a:131072:b"); +} + /// Round-trip: create, insert through named placeholders, and read a row back /// keyed by column name. #[test] @@ -52,6 +273,25 @@ $db->exec("DROP TABLE pg_rt"); assert_eq!(out, "1:Ada:9.5"); } +/// P2-j: a multi-statement `exec()` string (rejected by the single-command +/// `execute()` path, so it falls back to the simple-query protocol) returns the +/// LAST command's affected-row count, mirroring php-src's `PQexec` — not `0`, +/// which the old `batch_execute`-based fallback always reported. +#[test] +#[ignore] +fn test_pgsql_exec_multi_statement_returns_last_command_count() { + let out = compile_and_run(&pg_program( + r#" +$db->exec("DROP TABLE IF EXISTS pg_multi"); +$db->exec("CREATE TABLE pg_multi (id INT)"); +$n = $db->exec("INSERT INTO pg_multi VALUES (1); INSERT INTO pg_multi VALUES (2), (3);"); +$db->exec("DROP TABLE pg_multi"); +echo $n; +"#, + )); + assert_eq!(out, "2"); +} + /// Positional `?` placeholders translate to `$1, $2` and bind by position. #[test] #[ignore] @@ -71,6 +311,42 @@ $db->exec("DROP TABLE pg_pos"); assert_eq!(out, "seven"); } +/// PostgreSQL's emulation flag selects the simple-query protocol, including SQL that +/// contains multiple commands and therefore cannot be server-side prepared as one unit. +#[test] +#[ignore] +fn test_pgsql_emulated_prepare_executes_multi_command_sql() { + let out = compile_and_run(&pg_program( + r#" +$enabled = $db->setAttribute(PDO::ATTR_EMULATE_PREPARES, true); +$stmt = $db->prepare("SELECT ? AS value; SELECT ? AS value"); +$stmt->execute([3, 9]); +echo ($enabled ? "enabled" : "failed") . "|" + . ($stmt->getAttribute(PDO::ATTR_EMULATE_PREPARES) ? "emulated" : "native") . "|" + . $stmt->fetchColumn(); +"#, + )); + assert_eq!(out, "enabled|emulated|9"); +} + +/// `Pdo\Pgsql::ATTR_DISABLE_PREPARES` independently selects execute-only simple-query +/// mode while the generic emulation attribute remains false. +#[test] +#[ignore] +fn test_pgsql_disable_prepares_executes_multi_command_sql() { + let out = compile_and_run(&pg_program( + r#" +$disabled = $db->setAttribute(Pdo\Pgsql::ATTR_DISABLE_PREPARES, true); +$stmt = $db->prepare("SELECT 11 AS value; SELECT 13 AS value"); +$stmt->execute(); +echo ($disabled ? "disabled" : "failed") . "|" + . ($db->getAttribute(Pdo\Pgsql::ATTR_DISABLE_PREPARES) ? "simple" : "prepared") . "|" + . $stmt->fetchColumn(); +"#, + )); + assert_eq!(out, "disabled|simple|13"); +} + /// `SERIAL` columns drive `lastInsertId()` (via `lastval()`). #[test] #[ignore] @@ -88,22 +364,55 @@ $db->exec("DROP TABLE pg_seq"); assert_eq!(out, "2"); } -/// Column types decode to PHP scalars: integer, double, boolean (0/1), text, and -/// SQL NULL. +/// F-CORE-18: on a FRESH connection (a new session — `lastval()` is +/// session-scoped, so this only holds because `pg_program` opens a new `PDO` +/// per test), `lastInsertId()` before any INSERT/`nextval()` fails — pg's +/// `lastval()` errors with SQLSTATE 55000 ("currval of sequence ... is not yet +/// defined in this session") rather than returning a fabricated `"0"` — and the +/// default EXCEPTION errmode surfaces that as a catchable `PDOException` whose +/// `errorInfo[0]` carries the real (non-success) SQLSTATE. A subsequent real +/// `SERIAL` insert still returns the real id, unaffected by the earlier failure. +#[test] +#[ignore] +fn test_pgsql_last_insert_id_no_sequence_throws() { + let out = compile_and_run(&pg_program( + r#" +$db->exec("DROP TABLE IF EXISTS pg_seq_fresh"); +$db->exec("CREATE TABLE pg_seq_fresh (id SERIAL PRIMARY KEY, n INTEGER)"); +$code = "no-throw"; +try { + $db->lastInsertId(); +} catch (PDOException $e) { + $code = $e->errorInfo[0]; +} +$db->exec("INSERT INTO pg_seq_fresh (n) VALUES (42)"); +$id = $db->lastInsertId(); +$db->exec("DROP TABLE pg_seq_fresh"); +echo (strlen($code) === 5 && $code !== "00000") ? "err-ok" : $code; +echo ":" . $id; +"#, + )); + assert_eq!(out, "err-ok:1"); +} + +/// Column types decode to PHP scalars: integer, double, native boolean, text, and +/// SQL NULL; bytea is exposed as the read stream returned by php-src. #[test] #[ignore] fn test_pgsql_type_decoding() { let out = compile_and_run(&pg_program( r#" $db->exec("DROP TABLE IF EXISTS pg_types"); -$db->exec("CREATE TABLE pg_types (i INTEGER, d DOUBLE PRECISION, flag BOOLEAN, t TEXT, n TEXT)"); -$db->exec("INSERT INTO pg_types VALUES (42, 3.5, true, 'hi', NULL)"); -$row = $db->query("SELECT i, d, flag, t, n FROM pg_types")->fetch(PDO::FETCH_ASSOC); -echo $row["i"] . "|" . $row["d"] . "|" . $row["flag"] . "|" . $row["t"] . "|" . (is_null($row["n"]) ? "NULL" : "x"); +$db->exec("CREATE TABLE pg_types (i INTEGER, d DOUBLE PRECISION, flag BOOLEAN, t TEXT, n TEXT, b BYTEA)"); +$db->exec("INSERT INTO pg_types VALUES (42, 3.5, true, 'hi', NULL, decode('410042', 'hex'))"); +$row = $db->query("SELECT i, d, flag, t, n, b FROM pg_types")->fetch(PDO::FETCH_ASSOC); +echo $row["i"] . "|" . $row["d"] . "|" . (is_bool($row["flag"]) ? "bool:" : "not-bool:") . ($row["flag"] ? "1" : "0") + . "|" . $row["t"] . "|" . (is_null($row["n"]) ? "NULL" : "x") + . "|" . (is_resource($row["b"]) ? bin2hex(stream_get_contents($row["b"])) : "not-resource"); $db->exec("DROP TABLE pg_types"); "#, )); - assert_eq!(out, "42|3.5|1|hi|NULL"); + assert_eq!(out, "42|3.5|bool:1|hi|NULL|410042"); } /// A `PDOStatement` is Traversable: `foreach` walks the result set in the current @@ -148,6 +457,22 @@ $db->exec("DROP TABLE pg_tx"); assert_eq!(out, "1:2"); } +/// A raw PostgreSQL BEGIN is visible to PDO::inTransaction() and the ordinary +/// PDO::commit() guard even though beginTransaction() bookkeeping was bypassed. +#[test] +#[ignore] +fn test_pgsql_raw_transaction_is_visible() { + let out = compile_and_run(&pg_program( + r#" +$db->exec("BEGIN"); +echo ($db->inTransaction() ? "in" : "out") . ":"; +$db->commit(); +echo ($db->inTransaction() ? "in" : "out"); +"#, + )); + assert_eq!(out, "in:out"); +} + /// Rich PostgreSQL types decode to their text representation: `numeric` keeps its /// scale, date/time/timestamp use PostgreSQL's text format, `uuid` and `json` /// round-trip, and a `numeric` value binds through a parameter (coerced from @@ -197,3 +522,611 @@ echo ":" . (($db->exec("ALSO BAD") === false) ? "false" : "other"); )); assert_eq!(out, "caught:false"); } + +/// `Pdo\Pgsql::getPid()` returns the live PostgreSQL backend process id (a positive +/// integer). Constructed as the driver subclass directly, since `getPid` is not on +/// the base `PDO`, and driven against the live server. +#[test] +#[ignore] +fn test_pgsql_get_pid() { + let out = compile_and_run( + "getPid() > 0 ? \"pid-ok\" : \"pid-bad\";\n", + ); + assert_eq!(out, "pid-ok"); +} + +/// Verifies PHP 8.4's legacy pdo_pgsql extension methods remain installed on a +/// base `PDO` connection and share the modern bridge behavior. +#[test] +#[ignore] +fn test_pgsql_legacy_driver_extension_methods() { + let out = compile_and_run(&pg_program( + r#" +$db->exec("DROP TABLE IF EXISTS pg_legacy"); +$db->exec("CREATE TABLE pg_legacy (id INT, name TEXT)"); +$copied = $db->pgsqlCopyFromArray("pg_legacy", ["1\tAda", "2\tBob"]); +$rows = $db->pgsqlCopyToArray("pg_legacy", "\t", "\\N", "id, name"); +$pid = $db->pgsqlGetPid(); +$none = $db->pgsqlGetNotify(PDO::FETCH_NUM, 0); +$db->beginTransaction(); +$oid = $db->pgsqlLOBCreate(); +$opened = $oid === false ? false : $db->pgsqlLOBOpen((string) $oid); +$unlinked = $oid === false ? false : $db->pgsqlLOBUnlink((string) $oid); +$rowCount = -1; +if (is_array($rows)) { + $rowCount = count($rows); +} +$db->rollBack(); +$db->exec("DROP TABLE pg_legacy"); +echo ($copied ? "copy" : "bad") . ":" . $rowCount . ":"; +echo ($pid > 0 ? "pid" : "bad") . ":" . ($none === false ? "none" : "bad") . ":"; +echo (($opened !== false && $unlinked) ? "lob" : "bad"); +"#, + )); + assert_eq!(out, "copy:2:pid:none:lob"); +} + +/// `Pdo\Pgsql::lobCreate()` returns a new large object's OID (a numeric string) and +/// `lobUnlink()` deletes it, both driven against the live server. +#[test] +#[ignore] +fn test_pgsql_lob_create_unlink() { + let out = compile_and_run( + r#"beginTransaction(); +$oid = $db->lobCreate(); +$ok = ($oid !== false && is_numeric($oid)) ? "1" : "0"; +$unlinked = $db->lobUnlink((string) $oid) ? "1" : "0"; +$db->commit(); +echo $ok . $unlinked; +"#, + ); + assert_eq!(out, "11"); +} + +/// `Pdo\Pgsql::copyFromArray()` streams rows into a table via COPY FROM STDIN and +/// `copyToArray()` reads them back via COPY TO STDOUT (default tab/`\N` format). +/// +/// `copyToArray()` is typed `array|false` (P2-i), so `$rows` must be narrowed before +/// `count()`/`implode()` accept it as an array. The checker's flow-sensitive guard +/// narrowing (`src/types/checker/stmt_check/narrowing.rs`) recognizes `is_array($rows)` +/// (alongside `is_bool`/`is_null`/`instanceof` and the `=== false`/`!== false` / +/// `=== null` comparisons), narrowing the union down to `array` for `count()`/`implode()`. +#[test] +#[ignore] +fn test_pgsql_copy_from_to_array() { + let out = compile_and_run( + r#"exec("DROP TABLE IF EXISTS elephc_copy"); +$db->exec("CREATE TABLE elephc_copy (id INT, name TEXT)"); +$ok = $db->copyFromArray("elephc_copy", ["1\tAda", "2\tBob"]) ? "1" : "0"; +$rows = $db->copyToArray("elephc_copy"); +$db->exec("DROP TABLE elephc_copy"); +if (is_array($rows)) { + $joined = implode("", $rows); + echo $ok . ":" . count($rows) . ":" . (strpos($joined, "Ada") !== false ? "y" : "n") . (strpos($joined, "Bob") !== false ? "y" : "n"); +} else { + echo $ok . ":err"; +} +"#, + ); + assert_eq!(out, "1:2:yy"); +} + +/// P2-i: `copyToArray()` distinguishes a genuinely empty table (`[]`) from a +/// failed COPY (`false`, widened from the old `array`-only return type) — +/// `COPY` against a nonexistent table fails at the server, and must not read +/// back as an empty result. +/// +/// The empty-vs-array check uses `$empty === []` directly: the deep array strict-equality +/// helper (`__rt_array_strict_eq`) compares a `Union(Array, Bool)`-typed value (what +/// `array|false` lowers to) against an array literal by structure rather than heap-pointer +/// identity, so an empty result reads back as strictly equal to `[]` while a `false` +/// failure does not. `$error === false` compares the `false` alternative directly. +#[test] +#[ignore] +fn test_pgsql_copy_to_array_distinguishes_empty_from_error() { + let out = compile_and_run( + r#"exec("DROP TABLE IF EXISTS elephc_copy_empty"); +$db->exec("CREATE TABLE elephc_copy_empty (id INT)"); +$empty = $db->copyToArray("elephc_copy_empty"); +$db->exec("DROP TABLE elephc_copy_empty"); +$error = $db->copyToArray("elephc_copy_does_not_exist"); +echo ($empty === [] ? "empty-ok" : "empty-bad") . ":" . ($error === false ? "error-ok" : "error-bad"); +"#, + ); + assert_eq!(out, "empty-ok:error-ok"); +} + +/// `Pdo\Pgsql::getNotify()` receives a LISTEN/NOTIFY notification: the session +/// listens on a channel, notifies it, and getNotify returns [channel, pid, payload] +/// without truncating an embedded tab in the payload. Driven against the live server. +#[test] +#[ignore] +fn test_pgsql_get_notify() { + let out = compile_and_run( + r#"exec("LISTEN elephc_ch"); +$db->exec("SELECT pg_notify('elephc_ch', E'hi\\tthere')"); +$n = $db->getNotify(\PDO::FETCH_NUM, 1000); +echo (count($n) === 0) ? "none" : ($n[0] . ":" . $n[2]); +"#, + ); + assert_eq!(out, "elephc_ch:hi\tthere"); +} + +/// `Pdo\Pgsql::getNotify(PDO::FETCH_ASSOC)` shapes the notification as +/// `["message"=>channel, "pid"=>pid, "payload"=>payload]` instead of the default +/// numerically-indexed triple (P2-5). Driven against the live server. +#[test] +#[ignore] +fn test_pgsql_get_notify_assoc() { + let out = compile_and_run( + r#"exec("LISTEN elephc_ch_assoc"); +$db->exec("NOTIFY elephc_ch_assoc, 'hi'"); +$n = $db->getNotify(\PDO::FETCH_ASSOC, 1000); +echo (count($n) === 0) ? "none" : ($n["message"] . ":" . $n["payload"] . ":" . ($n["pid"] > 0 ? "pid-ok" : "pid-bad")); +"#, + ); + assert_eq!(out, "elephc_ch_assoc:hi:pid-ok"); +} + +/// P2-1: `PDO::ATTR_TIMEOUT` folds into the DSN as libpq's `connect_timeout` +/// conninfo key, so a connection attempt against an unreachable host fails +/// within a bounded time instead of hanging on the OS's own (much longer) TCP +/// connect timeout. Uses a non-routable TEST-NET-1 address (RFC 5737, +/// `192.0.2.0/24`) so the connect attempt reliably blackholes rather than +/// getting an immediate "connection refused". Driven without any live server +/// (the point is that the connection never completes). +#[test] +#[ignore] +fn test_pgsql_attr_timeout_fails_fast() { + let out = compile_and_run( + r#" 2]); + echo "connected"; +} catch (PDOException $e) { + $elapsed = microtime(true) - PgTimeoutClock::$start; + echo ($elapsed < 10.0) ? "fast" : "slow"; +} +"#, + ); + assert_eq!(out, "fast"); +} + +/// `Pdo\Pgsql::lobOpen()` returns a transaction-scoped seekable stream. The live +/// fixture reads an existing object, overwrites and extends it (including a seek +/// beyond EOF), verifies the write through SQL, and rejects a nonexistent OID. +#[test] +#[ignore] +fn test_pgsql_lob_open() { + let out = compile_and_run( + r#"beginTransaction(); +$oid = $db->query("SELECT lo_from_bytea(0, 'elephc-lo'::bytea)")->fetchColumn(); +$s = $db->lobOpen((string) $oid, "w+b"); +$content = stream_get_contents($s); +$seek = fseek($s, 7); +$written = fwrite($s, "LOB"); +fseek($s, 12); +fwrite($s, "!"); +$stored = $db->query("SELECT encode(lo_get(" . (string) $oid . "), 'hex')")->fetchColumn(); +$missing = $db->lobOpen("999999999") === false ? "false" : "leak"; +$db->lobUnlink((string) $oid); +$db->commit(); +echo $content . ":" . $seek . ":" . $written . ":" . $stored . ":" . $missing; +"#, + ); + assert_eq!(out, "elephc-lo:0:3:656c657068632d4c4f42000021:false"); +} + +/// Live TLS round-trip. Opens `ELEPHC_PG_TLS_DSN` — a DSN carrying `sslmode=require` +/// (or `sslmode=verify-full;sslrootcert=`) against a TLS-enabled PostgreSQL — +/// and confirms a query returns over the encrypted rustls (ring) connection. The +/// default `tls` feature is compiled into the linked staticlib, so no extra build +/// flag is needed. `#[ignore]` — needs a TLS-serving PostgreSQL. Example: +/// # server.crt/server.key must be owned by the postgres uid inside the container +/// docker run -d --name pgtls -e POSTGRES_PASSWORD=test -e POSTGRES_USER=test \ +/// -e POSTGRES_DB=testdb -p 55433:5432 -v "$PWD/certs":/certs postgres:16-alpine \ +/// -c ssl=on -c ssl_cert_file=/certs/server.crt -c ssl_key_file=/certs/server.key +/// ELEPHC_PG_TLS_DSN='pgsql:host=localhost;port=55433;dbname=testdb;user=test;password=test;sslmode=require' \ +/// cargo test --test codegen_tests -- --ignored pgsql_tls_round_trip +#[test] +#[ignore] +fn pgsql_tls_round_trip() { + let out = compile_and_run( + r#"query("SELECT 'tls-ok'")->fetchColumn(); +"#, + ); + assert_eq!(out, "tls-ok"); +} + +/// P2-k: `getColumnMeta()` on a `pgsql:` statement reports the column's REAL +/// PostgreSQL type — the server native_type name (`int4`/`bool`/`bytea`/`text`), +/// the correct pdo_type (INT4→PARAM_INT=1, BOOL→PARAM_BOOL=5, BYTEA→PARAM_LOB=3, +/// TEXT→PARAM_STR=2), and the `pgsql:oid` type OID (23/16/17/25) — instead of the +/// generic SQLite storage-class metadata elephc emitted for every driver before +/// ABI v23. The name/OID are threaded from the prepared statement's +/// `postgres::types::Type`, so they describe the DECLARED column type even though +/// the table is empty (no row is fetched here). +#[test] +#[ignore] +fn test_pgsql_get_column_meta_native_types() { + let out = compile_and_run(&pg_program( + r#" +$db->exec("DROP TABLE IF EXISTS pg_meta"); +$db->exec("CREATE TABLE pg_meta (id INT4, flag BOOL, payload BYTEA, label TEXT)"); +$stmt = $db->query("SELECT id, flag, payload, label FROM pg_meta"); +$parts = []; +for ($i = 0; $i < 4; $i++) { + $m = $stmt->getColumnMeta($i); + $parts[] = $m["native_type"] . ":" . $m["pdo_type"] . ":" . $m["pgsql:oid"]; +} +echo implode(",", $parts); +$db->exec("DROP TABLE pg_meta"); +"#, + )); + assert_eq!(out, "int4:1:23,bool:5:16,bytea:3:17,text:2:25"); +} + +/// v1 §6 gap: `Pdo\Pgsql::setNoticeCallback()` end-to-end. Registers a callback, +/// then runs a `DROP TABLE IF EXISTS` on a missing table — which the server +/// answers with a `NOTICE: ... does not exist, skipping` — and confirms the +/// buffered notice is drained and dispatched to the callback right after the +/// exec() (delivery is poll-based, not fired mid-protocol). Uses a plain +/// `DROP ... IF EXISTS` rather than a `DO $$ RAISE NOTICE $$` block so the fixture +/// needs no dollar-quoting. The callback asserts inside itself (echoing "got" on a +/// substring match, tolerant of libpq's severity prefix / trailing whitespace) +/// rather than accumulating into a `use (&$var)` by-reference capture: a by-ref +/// capture stored on the connection's callback property is avoided here so this +/// test isolates notice delivery from closure-reference behavior. +#[test] +#[ignore] +fn test_pgsql_set_notice_callback_e2e() { + let out = compile_and_run( + r#"setNoticeCallback(function($msg) { echo str_contains($msg, "does not exist") ? "got" : ("other:" . $msg); }); +$db->exec("DROP TABLE IF EXISTS pg_notice_probe_zzz"); +"#, + ); + assert_eq!(out, "got"); +} + +/// A NOTICE raised by a prepared statement is dispatched before `execute()` +/// returns, rather than remaining queued until a later connection query. +#[test] +#[ignore] +fn test_pgsql_prepared_execute_dispatches_notice_synchronously() { + let out = compile_and_run( + r#"setNoticeCallback(function($msg) { echo str_contains($msg, "elephc prepared notice") ? "notice" : "other"; }); +$stmt = $db->prepare("DO $$ BEGIN RAISE NOTICE 'elephc prepared notice'; END $$"); +$stmt->execute(); +echo ":after"; +"#, + ); + assert_eq!(out, "notice:after"); +} + +/// P2-a end-to-end: preparing a statement that mixes a positional `?` with a +/// named `:name` placeholder is rejected with SQLSTATE HY093 BEFORE the server is +/// asked to prepare it (from the placeholder scanner's `mixed` flag, whose unit +/// is `pg_translate_placeholders_mixed_flag`). Handles both dispositions — under +/// the default EXCEPTION errmode prepare() throws a PDOException carrying +/// errorInfo[0] = "HY093"; a silent errmode would instead return false with the +/// same SQLSTATE on the connection. +#[test] +#[ignore] +fn test_pgsql_mixed_placeholder_styles_reject_hy093() { + let out = compile_and_run(&pg_program( + r#" +try { + $r = $db->prepare("SELECT * FROM (VALUES (1)) v WHERE ? = :a"); + echo ($r === false) ? $db->errorInfo()[0] : "no-error"; +} catch (\PDOException $e) { + echo $e->errorInfo[0]; +} +"#, + )); + assert_eq!(out, "HY093"); +} + +/// F-PG-01 / F-PG-02 (v26): `getColumnMeta()` on a `pgsql:` statement now reports the +/// three fields it used to hardcode — `pgsql:table_oid` (PQftable), `len` (PQfsize) and +/// `precision` (PQfmod) — and reports them RAW, exactly as php-src's +/// `pgsql_stmt_describe` copies them off the wire +/// (`ext/pdo_pgsql/pgsql_statement.c:496-497` for len/precision; the `pgsql:table_oid` +/// key is added unconditionally in `pgsql_stmt_get_column_meta`). +/// +/// Three counter-intuitive semantics are pinned here on purpose, because each one is a +/// place a well-meaning "fix" would silently diverge from real PDO: +/// +/// * `pgsql:table_oid` is present on EVERY column, `0` included. `0` is `InvalidOid`, +/// the server's own answer for a column that is not a plain table column — here the +/// computed `id + 1` expression. php-src emits the key with no test at all, so +/// suppressing it on `0` would break `isset($meta['pgsql:table_oid'])` on exactly the +/// columns a caller is most likely to probe. A real table column reports the table's +/// `pg_class` OID, which is non-zero (it is assigned per-database, so only its sign +/// can be asserted). +/// * `len` is the TYPE's byte width when it has a fixed one and `-1` for any varlena. +/// `int4` reports 4; `VARCHAR(20)` reports **-1**, NOT 20; `NUMERIC(10,2)` reports +/// -1 too. Both are varlena types, and `PQfsize()` is `pg_type.typlen`. +/// * `precision` is the RAW `atttypmod`, undecoded. `VARCHAR(20)`'s declared 20 surfaces +/// HERE, as **24** (20 + `VARHDRSZ`), and `NUMERIC(10,2)` as **655366** +/// (`((10 << 16) | 2) + 4`). A type carrying no modifier (`int4`) reports -1. The +/// values were read back off a live pg16 with +/// `SELECT atttypmod FROM pg_attribute …` and match byte for byte. +#[test] +#[ignore] +fn test_pgsql_get_column_meta_table_oid_len_precision() { + let out = compile_and_run(&pg_program( + r#" +$db->exec("DROP TABLE IF EXISTS pg_meta_full"); +$db->exec("CREATE TABLE pg_meta_full (id INT4, label VARCHAR(20), money NUMERIC(10,2))"); +$stmt = $db->query("SELECT id, label, money, id + 1 AS expr FROM pg_meta_full"); +$id = $stmt->getColumnMeta(0); +$label = $stmt->getColumnMeta(1); +$money = $stmt->getColumnMeta(2); +$expr = $stmt->getColumnMeta(3); +$db->exec("DROP TABLE pg_meta_full"); +echo ((((int) $id["pgsql:table_oid"]) > 0) ? "tbl-y" : "tbl-n") + . ":" . $id["table"] . ":" . (isset($expr["table"]) ? "expr-table-y" : "expr-table-n") + . ":" . (isset($expr["pgsql:table_oid"]) ? "key-y" : "key-n") + . ":" . $expr["pgsql:table_oid"] + . "|" . $id["len"] . "," . $label["len"] . "," . $money["len"] + . "|" . $id["precision"] . "," . $label["precision"] . "," . $money["precision"]; +"#, + )); + assert_eq!( + out, + "tbl-y:pg_meta_full:expr-table-n:key-y:0|4,-1,-1|-1,24,655366" + ); +} + +/// F-PG-04 (Wave 1): an `oid` column is `PDO::PARAM_LOB` (3), not `PDO::PARAM_INT`. +/// php-src's pdo_pgsql type switch pairs the two cases literally — +/// `case OIDOID: case BYTEAOID:` (`ext/pdo_pgsql/pgsql_statement.c:690-706`) — because +/// to pdo_pgsql an OID is a large-object HANDLE, not an integer value: it is what you +/// feed to `lobOpen()`. Grouping it with INT2/INT4/INT8 (as elephc did before Wave 1) +/// made `getColumnMeta()` advertise a LOB handle as a plain integer. +/// +/// `len` is asserted alongside as 4 — `oid` is one of PostgreSQL's fixed-width 4-byte +/// types (`pg_type.typlen` = 4), so it does NOT take the varlena `-1` that `bytea`, its +/// partner in that same switch arm, reports. +#[test] +#[ignore] +fn test_pgsql_get_column_meta_oid_column_is_param_lob() { + let out = compile_and_run(&pg_program( + r#" +$m = $db->query("SELECT '1'::oid AS o")->getColumnMeta(0); +echo $m["native_type"] . ":" . $m["pdo_type"] . ":" . $m["pgsql:oid"] . ":" . $m["len"]; +"#, + )); + assert_eq!(out, "oid:3:26:4"); +} + +/// F-PG-05: a MULTI-character COPY separator is TRUNCATED TO ITS FIRST BYTE and the +/// COPY succeeds. PostgreSQL's COPY grammar admits only a one-byte `DELIMITER`, and all +/// four of php-src's COPY builders dereference exactly one byte of the argument — +/// `(pg_delim_len ? *pg_delim : '\t')` (`ext/pdo_pgsql/pgsql_driver.c:654,773,882,973`) — +/// silently dropping the rest. elephc used to interpolate the WHOLE string, so +/// `copyFromArray(…, "::")` emitted `DELIMITER '::'` and the SERVER rejected the +/// statement, where real PHP quietly copies with `:`. +/// +/// Round-tripped through `copyToArray()` with the same `"::"` so the truncation is +/// proved to be consistent in both directions: the rows go in split on `:` and come +/// back joined on `:`. +#[test] +#[ignore] +fn test_pgsql_copy_multi_char_separator_truncates_to_first_byte() { + let out = compile_and_run( + r#"exec("DROP TABLE IF EXISTS pg_copy_delim"); +$db->exec("CREATE TABLE pg_copy_delim (id INT, name TEXT)"); +$ok = $db->copyFromArray("pg_copy_delim", ["1:Ada", "2:Bob"], "::") ? "1" : "0"; +$rows = $db->copyToArray("pg_copy_delim", "::"); +$db->exec("DROP TABLE pg_copy_delim"); +if (is_array($rows)) { + $joined = implode("", $rows); + echo $ok . ":" . count($rows) . ":" . (strpos($joined, "1:Ada") !== false ? "y" : "n") . (strpos($joined, "2:Bob") !== false ? "y" : "n"); +} else { + echo $ok . ":err"; +} +"#, + ); + assert_eq!(out, "1:2:yy"); +} + +/// `Pdo\Pgsql::copyFromFile()` / `copyToFile()` round-trip through client-side files — +/// the two COPY methods no other fixture exercised at all. The file written by +/// `copyToFile()` must be byte-identical to the one `copyFromFile()` consumed (default +/// tab delimiter, `\N` NULL marker, trailing newline per row). +/// +/// php-src's own 8.4 source builds `COPY … TO STDIN` for `copyToArray`/`copyToFile` +/// (`ext/pdo_pgsql/pgsql_driver.c:882,884,973,975`) — an INVALID direction in +/// PostgreSQL's COPY grammar, which only knows `FROM STDIN` and `TO STDOUT`. elephc +/// correctly emits `TO STDOUT`. That divergence is DELIBERATE and must NOT be "aligned" +/// with php-src: aligning it would make every `copyTo*` call fail at the server. +#[test] +#[ignore] +fn test_pgsql_copy_from_file_to_file_round_trip() { + let out = compile_and_run( + r#"exec("DROP TABLE IF EXISTS pg_copy_file"); +$db->exec("CREATE TABLE pg_copy_file (id INT, name TEXT)"); +$src = tempnam(sys_get_temp_dir(), "elephc_pg_copy_in_"); +file_put_contents($src, "1\tAda\n2\tBob\n"); +$in = $db->copyFromFile("pg_copy_file", $src) ? "1" : "0"; +$dst = tempnam(sys_get_temp_dir(), "elephc_pg_copy_out_"); +$out = $db->copyToFile("pg_copy_file", $dst) ? "1" : "0"; +$back = (string) file_get_contents($dst); +unlink($src); +unlink($dst); +$db->exec("DROP TABLE pg_copy_file"); +echo $in . $out . ":" . (($back === "1\tAda\n2\tBob\n") ? "same" : "diff"); +"#, + ); + assert_eq!(out, "11:same"); +} + +/// F-PARSE-01 end-to-end, in the only two dispositions a live PostgreSQL admits. +/// +/// The first half is the executable one: a CHAINED cast (`:v::int::text`, two separate +/// two-colon runs) prepares and executes with its named bind intact — the `::` runs are +/// emitted verbatim and only `:v` becomes `$1`. +/// +/// The second half pins the odd-run rule, and it cannot be an "it executes" assertion: +/// PostgreSQL's lexer has no token for a 3+-colon run (`typecast` is exactly `"::"` and +/// a bare `:` is a self char legal only inside an array subscript), so `SELECT 1 :::c` +/// is a syntax error on a real server — verified against a live pg16, which answers +/// SQLSTATE **42601**. There is therefore NO valid SQL text carrying a 3+-colon run for +/// the placeholder scanner to translate, and "prepares and executes correctly" is +/// unattainable for one by construction. +/// +/// What the fix changes is WHOSE error it is. php-src's `MULTICHAR = [:]{2,}` +/// (`pgsql_sql_parser.re:35`) is greedy, so the whole run is one verbatim text token. +/// elephc's scanner used to eat colons PAIRWISE, leaving the third colon of an odd run +/// to be re-scanned as a fresh `:c` — a named bind php-src never emits. Next to the `?` +/// in the same statement that phantom bind set the `mixed` flag, and elephc rejected the +/// statement ITSELF with HY093 before the server ever saw it. Post-fix no named bind is +/// allocated, the text reaches the server unchanged, and the server delivers its own +/// verdict. So `42601` (and specifically NOT `HY093`) is the assertion that discriminates +/// the fixed scanner from the broken one. +#[test] +#[ignore] +fn test_pgsql_multi_colon_run_no_phantom_bind() { + let out = compile_and_run(&pg_program( + r#" +$st = $db->prepare("SELECT :v::int::text AS a"); +$st->execute([":v" => 41]); +$a = $st->fetchColumn(); +$code = ""; +try { + $bad = $db->prepare("SELECT 1 :::c , ? FROM (VALUES (1)) v(x)"); + $code = ($bad === false) ? $db->errorInfo()[0] : "prepared"; +} catch (\PDOException $e) { + $code = $e->errorInfo[0]; +} +echo $a . ":" . $code; +"#, + )); + assert_eq!(out, "41:42601"); +} + +/// F-PARSE-02 end-to-end: a dollar-quote TAG may carry non-ASCII bytes, so +/// `$café$ … $café$` is a real dollar-quoted string — php-src spells the classes +/// `DOLQ_START = [A-Za-z\200-\377_]` / `DOLQ_CONT = [A-Za-z\200-\377_0-9]` +/// (`pgsql_sql_parser.re:32-33`), matching PostgreSQL's own lexer. +/// +/// Gating the tag on `is_ascii_alphabetic()` meant the quote never opened: the body fell +/// through to the ordinary scanner and the `?` INSIDE the string literal was rewritten +/// into a positional bind. That did two things at once, and both are pinned here — it +/// corrupted the SQL text the server received, and, alongside the real `:n` named bind, +/// it set the `mixed` flag and got the statement rejected with HY093 before the server +/// saw it. Post-fix the body is copied through untouched (the `?` survives as literal +/// text in the result) and `:n` is the statement's only parameter. +/// +/// The SQL is single-quoted in PHP so `$café$` is not read as a variable interpolation. +#[test] +#[ignore] +fn test_pgsql_non_ascii_dollar_quote_tag_executes() { + let out = compile_and_run(&pg_program( + r#" +$st = $db->prepare('SELECT $café$a ? b$café$ AS dq, :n::text AS n'); +$st->execute([":n" => "ok"]); +$row = $st->fetch(PDO::FETCH_ASSOC); +echo $row["dq"] . "|" . $row["n"]; +"#, + )); + assert_eq!(out, "a ? b|ok"); +} + +/// F-PG-03: a connection with NO `PDO::ATTR_TIMEOUT` still fails in bounded time. +/// php-src's pgsql handle factory defaults libpq's `connect_timeout` to 30 s +/// (`ext/pdo_pgsql/pgsql_driver.c:1350,1373,1381`), and elephc now appends +/// `connect_timeout='30'` to the conninfo whenever the DSN supplies none — so a +/// blackholed address fails at ~30 s instead of waiting out the OS's own TCP connect +/// timeout (75 s+ on Linux, longer on macOS). +/// +/// `10.255.255.1` is a non-routable RFC 1918 address that silently drops packets rather +/// than answering `ECONNREFUSED`, which is what makes this test measure the TIMEOUT and +/// not the round trip. The 45 s bound is the guard: it is comfortably above the 30 s +/// default (leaving room for DNS/TLS setup on a loaded CI box) and comfortably below +/// every OS default, so the assertion can only fail if the default went missing. The +/// libpq timeout itself is what keeps the fixture from hanging the suite — the test +/// cannot outlive it. +#[test] +#[ignore] +fn test_pgsql_default_connect_timeout_bounds_unreachable_host() { + let out = compile_and_run( + r#"exec("DROP TABLE IF EXISTS pg_bool"); +$db->exec("CREATE TABLE pg_bool (id INT, flag BOOL)"); +$ins = $db->prepare("INSERT INTO pg_bool (id, flag) VALUES (?, ?)"); +$ins->bindValue(1, 1, PDO::PARAM_INT); +$ins->bindValue(2, true, PDO::PARAM_BOOL); +$ins->execute(); +$ins2 = $db->prepare("INSERT INTO pg_bool (id, flag) VALUES (?, ?)"); +$ins2->bindValue(1, 2, PDO::PARAM_INT); +$ins2->bindValue(2, false, PDO::PARAM_BOOL); +$ins2->execute(); +$selTrue = $db->prepare("SELECT id FROM pg_bool WHERE flag = ?"); +$selTrue->bindValue(1, true, PDO::PARAM_BOOL); +$selTrue->execute(); +$trueId = $selTrue->fetchColumn(); +$selFalse = $db->prepare("SELECT id FROM pg_bool WHERE flag = ?"); +$selFalse->bindValue(1, false, PDO::PARAM_BOOL); +$selFalse->execute(); +$falseId = $selFalse->fetchColumn(); +$db->exec("DROP TABLE pg_bool"); +echo $trueId . ":" . $falseId; +"#, + )); + assert_eq!(out, "1:2"); +} diff --git a/tests/codegen/pdo_sqlsrv.rs b/tests/codegen/pdo_sqlsrv.rs new file mode 100644 index 0000000000..051e9fc6f6 --- /dev/null +++ b/tests/codegen/pdo_sqlsrv.rs @@ -0,0 +1,121 @@ +//! Purpose: +//! End-to-end version-surface and live SQL Server tests for optional PDO_SQLSRV. +//! +//! Called from: +//! - `cargo test --features pdo-sqlsrv --test codegen_tests`. +//! +//! Key details: +//! - PDO_SQLSRV 5.13.1 supports PHP 8.3-8.5 and declares constants on `PDO` only. +//! - The ignored live test requires Microsoft ODBC Driver 18/17 and `ELEPHC_SQLSRV_DSN`. + +use crate::support::*; +use elephc::php_version::PhpVersion; + +/// Verifies PDO_SQLSRV 5.13.1's legacy-only PHP 8.3 constant surface. +#[test] +fn test_pdo_sqlsrv_surface_php83() { + assert_sqlsrv_surface(PhpVersion::Php83); +} + +/// Verifies PHP 8.4 does not invent a `Pdo\Sqlsrv` class absent upstream. +#[test] +fn test_pdo_sqlsrv_surface_php84() { + assert_sqlsrv_surface(PhpVersion::Php84); +} + +/// Verifies PHP 8.5 keeps PDO_SQLSRV constants non-deprecated and on `PDO`. +#[test] +fn test_pdo_sqlsrv_surface_php85() { + assert_sqlsrv_surface(PhpVersion::Php85); +} + +/// Verifies PDO_SQLSRV 5.13.1 is hidden from its unsupported PHP 8.2 target. +#[test] +fn test_pdo_sqlsrv_unavailable_php82() { + assert_sqlsrv_unavailable(PhpVersion::Php82); +} + +/// Verifies the profile stays hidden until Microsoft publishes PHP 8.6 support. +#[test] +fn test_pdo_sqlsrv_unavailable_php86() { + assert_sqlsrv_unavailable(PhpVersion::Php86); +} + +/// Compiles the shared PDO_SQLSRV constants/class-presence probe for one PHP version. +fn assert_sqlsrv_surface(version: PhpVersion) { + let out = compile_and_run_with_php_version( + r#" PDO::ERRMODE_EXCEPTION]); +echo $db->getAttribute(PDO::ATTR_DRIVER_NAME) . "|"; +$client = $db->getAttribute(PDO::ATTR_CLIENT_VERSION); +$server = $db->getAttribute(PDO::ATTR_SERVER_INFO); +echo $client["ExtensionVer"] . ":" . (isset($client["DriverVer"]) ? "client" : "missing") . ":" . (isset($server["SQLServerVersion"]) ? "server" : "missing") . "|"; +echo $db->quote("é'", PDO::PARAM_STR) . "|"; +$db->exec("CREATE TABLE #elephc_pdo_sqlsrv (id INT IDENTITY(1,1), amount DECIMAL(10,2), happened DATETIME2, label NVARCHAR(40))"); +$insert = $db->prepare("INSERT INTO #elephc_pdo_sqlsrv(amount, happened, label) VALUES (?, ?, ?)"); +$insert->execute([12.5, "2026-07-17 12:34:56", "éléphant"]); +echo $db->lastInsertId() . "|"; +$stmt = $db->prepare( + "SELECT id, amount, happened, label FROM #elephc_pdo_sqlsrv", + [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL, PDO::SQLSRV_ATTR_CURSOR_SCROLL_TYPE => PDO::SQLSRV_CURSOR_BUFFERED, PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE => true, PDO::SQLSRV_ATTR_FETCHES_DATETIME_TYPE => true] +); +echo "prepared|"; +$stmt->execute(); +echo "executed|"; +$row = $stmt->fetch(PDO::FETCH_ASSOC); +echo "fetched|"; +echo gettype($row["id"]) . ":"; +echo $row["id"] . ":"; +echo get_class($row["happened"]) . ":"; +echo $row["label"] . "|"; +$meta = $stmt->getColumnMeta(0); +echo $meta["native_type"] . ":" . $meta["sqlsrv:decl_type"] . ":" . $meta["pdo_type"] . ":" . $meta["name"]; +}} catch (Throwable $fatal) {{ echo "FAIL:" . $fatal->getMessage(); }} +"# + ); + let out = compile_and_run(&source); + assert!( + out.starts_with("sqlsrv|5.13.1:client:server|N'"), + "unexpected PDO_SQLSRV output: {out}" + ); + assert!( + out.contains("|1|prepared|executed|fetched|integer:1:DateTime:éléphant|string:"), + "unexpected PDO_SQLSRV output: {out}" + ); +} diff --git a/tests/codegen/regressions/arrays.rs b/tests/codegen/regressions/arrays.rs index e1d8b86c51..8e7718d5e8 100644 --- a/tests/codegen/regressions/arrays.rs +++ b/tests/codegen/regressions/arrays.rs @@ -966,6 +966,753 @@ echo $a[0], ":", $b[0]; assert_eq!(out, "5:6"); } +/// x86_64-only regression pin for `__rt_array_get_mixed_key`'s hash-storage branch +/// (`src/codegen_support/runtime/arrays/array_get_mixed_key.rs`, +/// `__rt_array_get_mixed_key_hash`). Mixing an int and a string value gives `$a` a +/// statically Mixed element type, and the string-keyed write promotes its runtime +/// storage from indexed (kind 2) to hash (kind 3) via `__rt_array_set_mixed_key`. +/// Reading it back through a non-literal string key routes through +/// `Op::ArrayGetMixedKey`, whose x86_64 hash branch previously read `__rt_hash_get`'s +/// real return registers (`rax`=found, `rdi`=value_lo, `rsi`=value_hi, `rcx`=value_tag) +/// as if they mirrored the ARM64 convention (`rsi`=value_lo, `rdx`=value_hi, +/// `rcx`=value_tag) — `rdx` was never set by `__rt_hash_get`, so a garbage pointer got +/// boxed into the returned `Mixed` cell and SIGSEGV'd on first deref. The ARM64 branch +/// was always correct, so this test is only meaningful — and only regresses — on +/// x86_64. +#[test] +fn test_array_get_mixed_key_hash_storage_string_key_roundtrip() { + let out = compile_and_run( + r#" u64 { + let source = format!( + r#" [1]]; +$c = $a["k"]; +$a["k"][] = 2; +echo count($c), ":", count($a["k"]), "\n"; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "1:2\n"); +} + +/// Verifies the same nested-append COW hand-off for an integer-keyed outer array. +/// +/// Indexed and associative containers share PHP value semantics: detaching the outer slot may +/// make an unaliased bucket unique, but an independently held bucket value must still force the +/// append to split. This pins the indexed `SlotDetach` path specifically. +#[test] +fn test_indexed_nested_append_still_copies_a_shared_bucket() { + let out = compile_and_run_capture( + r#" u64 { + let source = format!( + r#"= 2 — stayed inert and every write in the callee landed straight in the CALLER's +/// storage. `function f(array $a) { $a[] = 1; }` modified the caller's array, on all three write +/// paths (mixed key, int key, append). +/// +/// Each caller-side assertion below LAUNDERS its read through a function call. That is not +/// stylistic: a direct `$src[0]` after the call is CONSTANT-FOLDED — the optimizer folds the +/// literal, correctly assuming by-value semantics — so it prints the right answer while the +/// runtime mutates underneath. A test written with direct reads passes against the bug. +#[test] +fn test_array_parameters_are_passed_by_value() { + let out = compile_and_run_capture( + r#"iterators[$i]`. And that +/// is not a corner case: `$i++` lowers to `Op::IChecked*`, whose PHP result type is `Mixed`, so +/// EVERY incremented loop counter is statically `Mixed` from its second use on. (Widening it +/// naively regressed four `MultipleIterator` tests.) +/// +/// Instead the key keeps `Op::ArrayGet` — whose result stays the array's element type — and is +/// simply no longer coerced; the codegen materializes it on both storage kinds. PHP's +/// numeric-string key rule then comes for free from `materialize_hash_key`: `"1"` IS the integer +/// key 1, while `"foo"` is a genuine string key, which never exists in packed storage. +#[test] +fn test_mixed_typed_key_reads_the_right_element() { + let out = compile_and_run_capture( + r#"b[$k][] = $v` desugars to the same read/push/write-back triple as a local base, and hit +/// the same defect: nothing created the missing inner array, so the first push into every bucket +/// read back a boxed null and silently DROPPED the value. `count()` returned 0. +/// +/// The fusion vivifies through the ordinary `PropertyArrayAssign` lowering — the very one the +/// group's own write-back uses — because the append temporary's checker type is the container's +/// VALUE type (typically `Mixed`), so assigning a bare `Array(Never)` literal into it would bypass +/// the boxing the storage expects and the bucket would segfault the moment it outgrew its initial +/// capacity. The growth case below pins exactly that. +/// +/// A property base deliberately does NOT get `Op::SlotDetach`: that op republishes the possibly +/// rehashed container pointer through a LOCAL slot, and on a property the new pointer would never +/// reach the property. So the property base is correct but still quadratic. +#[test] +fn test_nested_append_vivifies_on_a_property_base() { + let out = compile_and_run_capture( + r#"b[$k][] = $v; + } +} +$bag = new Bag(); +$bag->add("a", 1); +$bag->add("b", 2); +$bag->add("a", 3); +echo count($bag->b), ":", count($bag->b["a"]), ":", count($bag->b["b"]), "\n"; + +$grow = new Bag(); +for ($i = 0; $i < 12; $i++) { + $grow->add("k", $i); +} +echo count($grow->b["k"]), ":", $grow->b["k"][11], "\n"; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "2:2:1\n12:11\n"); +} + +/// Verifies nested append auto-vivifies on a STATIC-property base. +/// +/// This shape needed two separate fixes. The parser was dropping the append outright — +/// `self::$b[$k][] = $v` compiled as `self::$b[$k] = $v`, overwriting the bucket — and once that +/// was routed through the desugar it inherited the missing auto-vivification and lost the first +/// row of every bucket instead. Both are fixed; this pins the pair. +#[test] +fn test_nested_append_vivifies_on_a_static_property_base() { + let out = compile_and_run_capture( + r#"b["k"] = [1]; +$snapshot = $bag->b["k"]; +$bag->b["k"][] = 2; +echo count($snapshot), ":", count($bag->b["k"]), "\n"; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "1:2\n"); +} + +/// Verifies a store into a `Mixed`-typed property BOXES its value. +/// +/// An UNTYPED property becomes `Mixed` as soon as two different types are assigned to it (here +/// `$this->pos = 0` and `$this->pos = $this->pos + 1`). A `Mixed` slot holds a boxed cell — but +/// `Op::PropSet` was handed the value exactly as lowered, so `$this->pos = 0` wrote a RAW integer +/// into it, and reading it back dereferenced that integer as a cell pointer. `var_dump($this->pos)` +/// printed **NULL** right after assigning `0` to it. +/// +/// This bug survived because ANOTHER bug masked it perfectly: the array-read path used to coerce a +/// `Mixed` key with `__rt_mixed_cast_int`, and casting that null cell gives `0` — so `$names[$this->pos]` +/// read element 0 and returned the right answer, by accident. Reading the key correctly is what +/// exposed it. Two bugs propping each other up. +#[test] +fn test_store_into_a_mixed_typed_property_is_boxed() { + let out = compile_and_run_capture( + r#"pos = 0; + } + + public function bump(): void { + $this->pos = $this->pos + 1; + } +} +$c = new Cursor(); +var_dump($c->pos); +$c->reset(); +var_dump($c->pos); +$c->bump(); +var_dump($c->pos); +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "int(0)\nint(0)\nint(1)\n"); +} + +/// The end-to-end shape the two bugs above conspired to hide: an untyped cursor property used as an +/// array index, advanced across calls. It is exactly `dir_readdir()`'s body in a stream wrapper. +#[test] +fn test_untyped_cursor_property_indexes_an_array_across_calls() { + let out = compile_and_run_capture( + r#"pos >= 2) { + return ""; + } + $n = $names[$this->pos]; + $this->pos = $this->pos + 1; + return $n; + } +} +$r = new Reader(); +echo $r->next(), "|", $r->next(), "|", $r->next(), "|\n"; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "a.txt|b.txt||\n"); +} + +/// A nullable-int array element (`?int` — `TaggedScalar`) has NO hash representation: neither +/// `hash_get` nor `hash_set` can materialize one. The storage-kind dispatch added to `ArrayGet` +/// emits its promoted-hash branch *speculatively* (an `Array(_)` local may be hash-backed at +/// runtime), and emitting it for such an element type does not sit unreached — it fails the whole +/// compilation with `unsupported EIR backend feature: hash_get value PHP type TaggedScalar`. +/// +/// This shape lives in `ir_backend_smoke_test`, a binary the codegen suite does not cover, so it +/// went unseen until the x86_64 run. Anchor it here, in the suite that is actually run. +#[test] +fn test_nullable_int_array_elements_still_compile_after_kind_dispatch() { + let out = compile_and_run_capture( + r#" `Array(Mixed)`), so from the +/// second iteration `count($m[0])` read a boxed cell as an array header: `1 5 5 5` instead of +/// `1 2 3 4`. The loop lowerings now pre-widen loop-carried arrays in the preheader and re-lower +/// the body against the widened environment (`stmt::lower_loop_at_type_fixpoint`). +#[test] +fn test_loop_body_nested_push_widening_is_prewidened_in_preheader() { + let out = compile_and_run_capture( + r#" 0) { $m[1] = "s"; } + echo $m[0], "\n"; +} +f(1); +f(0); +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "1\n0\n"); +} + +/// The else arm REBUILDS the array, so no conversion placed in a dominator of the `if` could ever +/// fix it: whatever the preheader (or the block before the `if`) converts, `$m = [...]` overwrites +/// with a fresh, concretely-typed array. The conversion has to live on the arm's own exit edge. +#[test] +fn test_if_else_arm_rebuilt_array_is_converted_on_its_own_exit_edge() { + let out = compile_and_run_capture( + r#" 0) { $m[1] = "s"; } else { $m = [$c + 7, $c + 8, $c + 9]; } + echo $m[0], "|", $m[1], "\n"; +} +f(1); +f(0); +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "1|s\n7|8\n"); +} + +/// The same defect through an `elseif` chain. Every arm of the chain branches to ONE merge, so the +/// join has to be computed across all of them at once — a pairwise join per recursion level would +/// reconcile the wrong pairs. This used to lose most of the program's output. +#[test] +fn test_if_elseif_chain_joins_every_arm_against_one_merge() { + let out = compile_and_run_capture( + r#" 0) { $m[] = $c; } else { $m[] = "s"; } + echo $m[0], "\n"; +} +e(1); +e(0); +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "1\ns\n"); +} + +/// `switch` case bodies share ONE forward-leaking environment and PHP fall-through gives each case +/// head two predecessors, so no exit-edge join can reconcile them. The statement-level fixed point +/// does: the whole `switch` is one region, its conversion is hoisted to the statement's entry (which +/// dominates and precedes every case), and the bodies are re-lowered against the converted array. +#[test] +fn test_switch_case_array_widening_does_not_leak_into_later_cases() { + let out = compile_and_run_capture( + r#" 0) { throw new Exception("x"); } + $m[1] = "s"; + } catch (Exception $e) { + echo $m[0], "\n"; + } + echo $m[0], "\n"; +} +t(1); +t(0); +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "1\n1\n0\n"); +} + +/// A ternary — no loop, no `if`, no `switch`, no `match`. The then-arm's element write re-typed `$m` +/// for everything lowered after it, but at runtime the conversion only ran when the condition was +/// true, so `t(0)` read raw slots through boxed loads and produced NO OUTPUT AT ALL (exit 0). +/// +/// The conversion cannot be hoisted to the ternary's own entry — a sibling operand of the enclosing +/// expression would already hold the array pointer — so it is hoisted to the STATEMENT's entry, +/// where nothing has been emitted yet. +#[test] +fn test_ternary_arm_array_widening_runs_on_every_path() { + let out = compile_and_run_capture( + r#" $m[0] = "s", + default => "d", + }; + echo $m[1], "|", $r, "\n"; +} +mm(0); +mm(1); +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "2|d\n2|s\n"); +} + +/// ANTI-REGRESSION for the fix itself, and the reason the region is the STATEMENT and not the +/// construct. Argument 0 is emitted BEFORE `match` is lowered and holds the array pointer with no +/// refcount protection, and `__rt_array_to_mixed` rewrites the slots IN PLACE at refcount 1 — so +/// converting at the MATCH's entry would run the conversion on the `default` path (where it does not +/// run today) and corrupt an argument that is already correct. `g(0)` is right before the fix and +/// must stay right after it. +/// +/// Hoisting to the statement's entry keeps it right because argument 0 is then RE-LOWERED against +/// the converted array — and the checker follows the same conversion predicate, so the callee is +/// specialized for `array` instead of being handed boxed slots it would read as scalars. +#[test] +fn test_call_argument_beside_a_widening_match_arm_stays_correct() { + let out = compile_and_run_capture( + r#" $m[0] = "s", default => "d" }); +} +g(0); +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "0/2/d\n"); +} + +/// A loop with a STRAIGHT-LINE body — no `if`, no inner branching — that reads the array ABOVE the +/// widening write. The read (`echo $a[0]`) is compiled once, at loop entry, against `Array(Int)`; +/// on iteration 2 the write has already re-boxed every slot, so without pre-widening in the +/// preheader the read dereferences a boxed cell pointer as a raw integer. The compile-time gate that +/// skips straight-line STATEMENTS must NOT skip a loop body, whose back edge is itself the hazard. +#[test] +fn test_loop_with_straight_line_widening_body_pre_widens_in_preheader() { + let out = compile_and_run_capture( + r#" null]; } + return [null]; +} +$packed = dynamicArray(false); +$hash = dynamicArray(true); +if (is_array($packed)) { + echo (array_key_exists(0, $packed) ? "p1" : "p0") . "|"; + echo (array_key_exists(1, $packed) ? "p1" : "p0") . "|"; +} +if (is_array($hash)) { + echo (array_key_exists("k", $hash) ? "h1" : "h0") . "|"; + echo (array_key_exists("missing", $hash) ? "h1" : "h0"); +} +"#, + ); + assert_eq!(out, "p1|p0|h1|h0"); +} + /// Regression for issue #526: `isset()` over a chained subscript whose first /// index misses is false, silent, and must not crash. #[test] diff --git a/tests/codegen/regressions/closures_and_refs.rs b/tests/codegen/regressions/closures_and_refs.rs index dc58599a52..d030a916f5 100644 --- a/tests/codegen/regressions/closures_and_refs.rs +++ b/tests/codegen/regressions/closures_and_refs.rs @@ -227,3 +227,43 @@ echo $a["n"]; ); assert_eq!(out, "2"); } + +/// Verifies a hash write through a *reference-bound* local survives a table reallocation. +/// +/// `lower_inst::hashes::source_load_local_slot` recognized only `Op::LoadLocal`, while its +/// indexed-array twin (`lower_inst::arrays::source_load_local_slot`) also recognizes +/// `Op::LoadRefCell`. So `$r = &$a; $r[$k] = $v;` found no destination slot and silently +/// discarded the table pointer `__rt_hash_set` hands back — which is a *new* pointer whenever +/// the table rehashes past its load factor or is COW-split. The stale pointer then kept being +/// read: building a 41-key hash through a ref reported a garbage count (a wild read), and the +/// same shape on an object property lost 37 of the 41 entries. +/// +/// Three or four keys are not enough to trigger it — the hash must actually grow — so this +/// test deliberately crosses the rehash threshold. +#[test] +fn test_hash_write_through_ref_bound_local_survives_rehash() { + let out = compile_and_run_capture( + r#" 0]; +$r = &$a; +for ($i = 0; $i < 40; $i++) { + $r["k" . $i] = $i; +} +echo count($a), "\n"; + +$o = new Holder(); +$o->v["seed"] = 0; +$p = &$o->v; +for ($i = 0; $i < 40; $i++) { + $p["k" . $i] = $i; +} +echo count($o->v), "\n"; +echo $o->v["k39"], "\n"; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "41\n41\n39\n"); +} diff --git a/tests/codegen/regressions/method_array_assoc_param.rs b/tests/codegen/regressions/method_array_assoc_param.rs index 5a5cd872c0..ba2e7d606f 100644 --- a/tests/codegen/regressions/method_array_assoc_param.rs +++ b/tests/codegen/regressions/method_array_assoc_param.rs @@ -101,3 +101,45 @@ echo W::at(['x', 'y']); ); assert_eq!(out, "xy"); } + +/// `isset()` dispatches on the runtime tag of each Mixed-valued associative-array entry before it +/// attempts to unbox the entry payload. +/// +/// PDO SQLSRV exposed this through repeated `prepare()` option dispatch: the nested option helper +/// passed raw integer `42` to `__rt_mixed_unbox` as though it were a boxed pointer. The null entry +/// covers the concrete tag-8 path, while the final reads prove the non-null entry remains intact. +#[test] +fn test_nested_method_array_param_isset_handles_concrete_mixed_entries() { + let out = compile_and_run( + r#"configure($options, 1000); + $null = $this->configure($options, 1004); + $present = $this->configure($options, 1003); + return $missing . ":" . $null . ":" . $present . ":" . $options[1003]; + } + + private function configure(array $options, int $option): int { + if (!isset($options[$option])) { + return -1; + } + return $this->asInt($options[$option]); + } + + private function asInt(mixed $value): int { + return (int) $value; + } +} + +$options = [10 => 1, 1003 => 42, 1004 => null, 1006 => true, 1007 => true]; +echo (new Options())->prepare($options), ":", $options[1003]; +$boxed = $options; +foreach ($boxed as &$entry) { +} +unset($entry); +echo "|", (new Options())->prepare($boxed); +"#, + ); + assert_eq!(out, "-1:-1:42:42:42|-1:-1:42:42"); +} diff --git a/tests/codegen/regressions/return_this_ownership.rs b/tests/codegen/regressions/return_this_ownership.rs index 380fdde46d..9f4c8b80c0 100644 --- a/tests/codegen/regressions/return_this_ownership.rs +++ b/tests/codegen/regressions/return_this_ownership.rs @@ -93,6 +93,70 @@ echo "after=" . N::$alive; assert_eq!(out, "alive=1\nafter=0"); } +/// A chained temporary that owns a reference back to a live parent releases that +/// property after its method call, so overwriting the parent's last local can run +/// its destructor immediately instead of leaking through the discarded child. +#[test] +fn test_chained_temporary_releases_parent_owner_property() { + let out = compile_and_run( + r#"child(); + $child->value(); + return $child; + } + public function __destruct() { ParentOwner::$alive = ParentOwner::$alive - 1; } +} +class OwnedChild { + private ParentOwner $owner; + public function __construct(ParentOwner $owner) { $this->owner = $owner; } + public function value(): int { return 7; } +} +function run(): void { + $owner = new ParentOwner(); + $child = $owner->query(); + echo $child->value() . ":"; + unset($child); + $owner = null; + echo ParentOwner::$alive; +} +run(); +"#, + ); + assert_eq!(out, "7:0"); +} + +/// Dynamic allocation without a constructor transfers its sole object owner into +/// the returned Mixed box, so releasing that box also releases object properties. +#[test] +fn test_dynamic_new_without_constructor_transfers_object_owner() { + let out = compile_and_run( + r#"owner = $owner; } + public function __destruct() { echo "child:"; } +} +$parent = new DynamicParentOwner(); +$child = __elephc_new_without_constructor("DynamicOwnedChild"); +$child->setOwner($parent); +unset($child); +$parent = null; +echo DynamicParentOwner::$alive; +"#, + ); + assert_eq!(out, "child:0"); +} + /// Assigning a fluent `return $this` result keeps the (aliased) object alive while /// the binding is in scope and frees it exactly once at scope end. #[test] diff --git a/tests/codegen/runtime_gc/regressions.rs b/tests/codegen/runtime_gc/regressions.rs index 4cc4ca7ca2..76bdbc420c 100644 --- a/tests/codegen/runtime_gc/regressions.rs +++ b/tests/codegen/runtime_gc/regressions.rs @@ -3021,6 +3021,84 @@ echo $bag->items[0]; ); } +/// Ensures widening a typed array into a generic `array` property preserves the +/// caller's source owner while transferring the unique converted clone to the property. +#[test] +fn test_property_array_widening_preserves_source_and_cow_ownership() { + let out = compile_and_run_with_heap_debug( + r#"items = $source; +echo $source[0], ",", $source[1]; +echo "|", $bag->items[0], ",", $bag->items[1]; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "11,22|11,22"); + assert!( + out.stderr.contains("HEAP DEBUG: leak summary: clean"), + "expected property widening ownership to stay balanced, got: {}", + out.stderr + ); +} + +/// Ensures associative-array widening follows the same non-consuming property +/// store contract while converting typed values to boxed `Mixed` entries. +#[test] +fn test_property_assoc_array_widening_preserves_source_ownership() { + let out = compile_and_run_with_heap_debug( + r#" 11, "right" => 22]; +$bag = new AssocArrayWideningBag(); +$bag->items = $source; +echo $source["left"], ",", $source["right"]; +echo "|", $bag->items["left"], ",", $bag->items["right"]; +"#, + ); + assert!(out.success, "program failed: {}", out.stderr); + assert_eq!(out.stdout, "11,22|11,22"); + assert!( + out.stderr.contains("HEAP DEBUG: leak summary: clean"), + "expected associative property widening ownership to stay balanced, got: {}", + out.stderr + ); +} + +/// Ensures releasing a temporary zero-property aggregate from a reused larger +/// heap block cannot invalidate a `Mixed` foreach key that is still live. +#[test] +fn test_iterator_aggregate_array_keys_survive_source_release() { + let out = compile_and_run( + r#" "L"]); +foreach ($first as $key => $value) { + echo $key, "=", $value, ";"; +} + +class RetainedKeyAggregate implements IteratorAggregate { + public function getIterator(): Traversable { + return new ArrayIterator(["base" => "B"]); + } +} + +$iterator = new IteratorIterator(new RetainedKeyAggregate()); +foreach ($iterator as $key => $value) { + echo $key, "=", $value; +} +"#, + ); + assert_eq!(out, "left=L;base=B"); +} + /// Verifies a nullsafe property read releases an owning nullable call result on /// both branches. In particular, a boxed null receiver must not leak when `?->` /// short-circuits before the property read. @@ -4237,20 +4315,13 @@ echo $sum, "\n"; ); } -/// Documents the container half of issue #619, which the conditional release deliberately does -/// NOT cover: a fresh `[$i]` handed to a callee that drops it still leaks one container per -/// call, through the long-standing `may_alias` suppression for array arguments. +/// Verifies a fresh container passed by value is released when the callee returns a non-alias. /// -/// The disambiguation compares two payloads as single pointers, which is only valid when both -/// sides are boxed `Mixed`. Here the argument is a bare container while the result is a `Mixed` -/// box that would *wrap* it, so the pointers differ even on the aliasing branch — comparing them -/// would release a container the result owns and abort with `bad refcount`. Covering this shape -/// needs the comparison to reach through the box to its payload field. -/// -/// The assertion pins the current leak on purpose, so that the restriction stays deliberate and -/// this test turns red the day the container path is covered. +/// User-code callees privatize by-value containers before execution, so their result cannot +/// retain the caller's original temporary. The caller may therefore release that temporary +/// without the payload-level alias guard required for builtin and extern calls. #[test] -fn test_conditional_return_callee_container_arg_still_leaks_on_non_alias_path() { +fn test_conditional_return_callee_container_arg_releases_on_non_alias_path() { let out = compile_and_run_with_heap_debug( r#" (String, String, TestLinkRequirements) { - compile_source_to_asm_with_defines_repr_and_regex( + compile_source_to_asm_with_defines_repr_regex_and_php_version( source, dir, &HashSet::new(), @@ -53,6 +53,7 @@ fn compile_source_to_asm_with_options_and_regex( heap_debug, default_null_repr(), with_regex, + elephc::php_version::PhpVersion::default(), ) } @@ -103,7 +104,7 @@ pub(crate) fn compile_source_to_asm_with_defines_repr( heap_debug: bool, null_repr: elephc::codegen::NullRepr, ) -> (String, String, TestLinkRequirements) { - compile_source_to_asm_with_defines_repr_and_regex( + compile_source_to_asm_with_defines_repr_regex_and_php_version( source, dir, defines, @@ -112,12 +113,38 @@ pub(crate) fn compile_source_to_asm_with_defines_repr( heap_debug, null_repr, false, + elephc::php_version::PhpVersion::default(), ) } -/// Runs the full fixture pipeline and optionally force-enables the runtime regex capability. +/// Runs the full fixture pipeline for an explicit PHP compatibility version. #[allow(clippy::too_many_arguments)] -fn compile_source_to_asm_with_defines_repr_and_regex( +pub(crate) fn compile_source_to_asm_with_defines_repr_and_php_version( + source: &str, + dir: &Path, + defines: &HashSet, + heap_size: usize, + gc_stats: bool, + heap_debug: bool, + null_repr: elephc::codegen::NullRepr, + php_version: elephc::php_version::PhpVersion, +) -> (String, String, TestLinkRequirements) { + compile_source_to_asm_with_defines_repr_regex_and_php_version( + source, + dir, + defines, + heap_size, + gc_stats, + heap_debug, + null_repr, + false, + php_version, + ) +} + +/// Runs the full fixture pipeline with explicit regex and PHP-version settings. +#[allow(clippy::too_many_arguments)] +fn compile_source_to_asm_with_defines_repr_regex_and_php_version( source: &str, dir: &Path, defines: &HashSet, @@ -126,6 +153,7 @@ fn compile_source_to_asm_with_defines_repr_and_regex( heap_debug: bool, null_repr: elephc::codegen::NullRepr, with_regex: bool, + php_version: elephc::php_version::PhpVersion, ) -> (String, String, TestLinkRequirements) { elephc::codegen::set_null_repr(null_repr); let tokens = elephc::lexer::tokenize(source).expect("tokenize failed"); @@ -137,7 +165,8 @@ fn compile_source_to_asm_with_defines_repr_and_regex( elephc::codegen::set_autoload_rule_count(autoload_registry.rule_count()); let resolved = elephc::resolver::resolve(ast, dir).expect("resolve failed"); let resolved = elephc::autoload::collect_aliases(resolved); - let resolved = elephc::pdo_prelude::inject_if_used(resolved, false); + let resolved = + elephc::pdo_prelude::inject_if_used_for_version(resolved, false, php_version); let resolved = elephc::tz_prelude::inject_if_used(resolved, false); let resolved = elephc::list_id_prelude::inject_if_used(resolved); let resolved = elephc::var_export_prelude::inject_if_used(resolved); @@ -218,25 +247,29 @@ fn ir_opt_enabled_for_codegen_fixture() -> bool { } } -// Injects an exit harness into user assembly before the final `ret` instruction. -// Rewrites macOS-style syscall sequence to Linux-style syscall sequence if needed, -// then patches the assembly in-place using a target-specific needle. Panics if the -// needle is not found (indicates a codegen emit change that broke the harness injection). -/// Injects main exit harness into the compiler metadata registry. -pub(crate) fn inject_main_exit_harness(asm: &str, harness: &str) -> String { - let needle = match (target().platform, target().arch) { +/// Returns the process-exit epilogue emitted for a supported test target. +fn main_exit_needle(target: Target) -> &'static str { + match (target.platform, target.arch) { (Platform::MacOS, Arch::AArch64) => " mov x0, #0\n mov x16, #1\n svc #0x80", - (Platform::Linux, Arch::AArch64) => " mov x0, #0\n mov x8, #93\n svc #0", - (Platform::Linux, Arch::X86_64) => " mov edi, 0\n mov eax, 60\n syscall", + (Platform::Linux, Arch::AArch64) => " mov x0, #0\n mov x8, #94\n svc #0", + (Platform::Linux, Arch::X86_64) => " mov edi, 0\n mov eax, 231\n syscall", (_, Arch::AArch64) => panic!( "main exit harness is not implemented yet for target {}", - target() + target ), (_, Arch::X86_64) => panic!( "main exit harness is not implemented yet for target {}", - target() + target ), - }; + } +} + +/// Injects an exit harness before the target's final process-exit epilogue. +/// +/// Transforms macOS-dialect harness assembly for Linux and panics when codegen no +/// longer emits the expected target-specific epilogue. +pub(crate) fn inject_main_exit_harness(asm: &str, harness: &str) -> String { + let needle = main_exit_needle(target()); // Harness strings are written in macOS assembly dialect; transform for Linux if needed let harness = target().transform_assembly(harness); let replacement = format!("{harness}\n{needle}"); @@ -524,6 +557,73 @@ pub(crate) fn compile_and_run_with_regex(source: &str) -> String { compile_and_run_with_heap_size_and_optional_regex(source, 8_388_608, true) } +/// Compiles and runs PHP source with an isolated `PHPRC` file containing `ini`. +pub(crate) fn compile_and_run_with_php_ini(source: &str, ini: &str) -> String { + let id = TEST_ID.fetch_add(1, Ordering::SeqCst); + let tid = std::thread::current().id(); + let pid = std::process::id(); + let dir = std::env::temp_dir().join(format!( + "elephc_test_php_ini_{}_{:?}_{}", + pid, tid, id + )); + fs::create_dir_all(&dir).unwrap(); + let ini_path = dir.join("php.ini"); + fs::write(&ini_path, ini).unwrap(); + + let (user_asm, runtime_asm, requirements) = + compile_source_to_asm_with_options(source, &dir, 8_388_608, false, false); + let runtime_obj = runtime_obj_for_asm(&runtime_asm); + let output = assemble_and_run_with_env( + &user_asm, + &runtime_obj, + &dir, + &requirements, + &default_link_paths(), + &[], + &[("PHPRC", ini_path.as_os_str())], + ); + let _ = fs::remove_dir_all(&dir); + output +} + +/// Compiles and runs PHP source with an explicit PHP compatibility version. +pub(crate) fn compile_and_run_with_php_version( + source: &str, + php_version: elephc::php_version::PhpVersion, +) -> String { + let id = TEST_ID.fetch_add(1, Ordering::SeqCst); + let tid = std::thread::current().id(); + let pid = std::process::id(); + let dir = std::env::temp_dir().join(format!( + "elephc_test_php_version_{}_{:?}_{}", + pid, tid, id + )); + fs::create_dir_all(&dir).unwrap(); + + let (user_asm, runtime_asm, requirements) = + compile_source_to_asm_with_defines_repr_and_php_version( + source, + &dir, + &HashSet::new(), + 8_388_608, + false, + false, + default_null_repr(), + php_version, + ); + let runtime_obj = runtime_obj_for_asm(&runtime_asm); + let output = assemble_and_run( + &user_asm, + &runtime_obj, + &dir, + &requirements, + &default_link_paths(), + &[], + ); + let _ = fs::remove_dir_all(&dir); + output +} + /// Compiles and runs a PHP source with the legacy sentinel null representation forced on, /// regardless of `ELEPHC_NULL_REPR`. Used by the sentinel opt-out guard tests. pub(crate) fn compile_and_run_sentinel(source: &str) -> String { @@ -614,3 +714,25 @@ pub(crate) fn asm_without_embedded_script_path(user_asm: &str) -> String { } out.join("\n") } + +#[cfg(test)] +mod exit_harness_tests { + use super::*; + + /// Verifies each supported target uses the process-wide exit epilogue emitted by codegen. + #[test] + fn main_exit_needles_match_supported_target_abis() { + assert_eq!( + main_exit_needle(Target::new(Platform::MacOS, Arch::AArch64)), + " mov x0, #0\n mov x16, #1\n svc #0x80" + ); + assert_eq!( + main_exit_needle(Target::new(Platform::Linux, Arch::AArch64)), + " mov x0, #0\n mov x8, #94\n svc #0" + ); + assert_eq!( + main_exit_needle(Target::new(Platform::Linux, Arch::X86_64)), + " mov edi, 0\n mov eax, 231\n syscall" + ); + } +} diff --git a/tests/codegen/support/mod.rs b/tests/codegen/support/mod.rs index 8554cfc214..844ba012b4 100644 --- a/tests/codegen/support/mod.rs +++ b/tests/codegen/support/mod.rs @@ -25,6 +25,12 @@ pub(crate) static RUNTIME_OBJ: OnceLock = OnceLock::new(); pub(crate) static RUNTIME_OBJS_BY_ASM: OnceLock>> = OnceLock::new(); pub(crate) static BRIDGE_STATICLIB_BUILD_LOCK: OnceLock> = OnceLock::new(); +pub(crate) static LIBPQ_BRIDGE_BUILT: OnceLock<()> = OnceLock::new(); +pub(crate) static DBLIB_BRIDGE_BUILT: OnceLock<()> = OnceLock::new(); +pub(crate) static FIREBIRD_BRIDGE_BUILT: OnceLock<()> = OnceLock::new(); +pub(crate) static ODBC_BRIDGE_BUILT: OnceLock<()> = OnceLock::new(); +pub(crate) static OCI_BRIDGE_BUILT: OnceLock<()> = OnceLock::new(); +pub(crate) static CUBRID_BRIDGE_BUILT: OnceLock<()> = OnceLock::new(); pub(crate) static QEMU_SYSROOT: OnceLock> = OnceLock::new(); pub(crate) static TEST_TARGET: OnceLock = OnceLock::new(); diff --git a/tests/codegen/support/platform.rs b/tests/codegen/support/platform.rs index 3d43f11206..6c4760a9be 100644 --- a/tests/codegen/support/platform.rs +++ b/tests/codegen/support/platform.rs @@ -66,13 +66,22 @@ pub(crate) fn gcc_cmd() -> &'static str { } /// Returns platform-specific library search paths used during linking. -/// On macOS checks `/opt/homebrew/lib` and `/usr/local/lib`. +/// On macOS checks common Homebrew roots plus optional PDO dependency prefixes. /// On Linux checks aarch64 sysroot paths. pub(crate) fn default_link_paths() -> Vec { let mut paths = Vec::new(); match target().platform { Platform::MacOS => { - for candidate in ["/opt/homebrew/lib", "/usr/local/lib"] { + for candidate in [ + "/opt/homebrew/lib", + "/usr/local/lib", + "/opt/homebrew/opt/libpq/lib", + "/usr/local/opt/libpq/lib", + "/opt/homebrew/opt/freetds/lib", + "/usr/local/opt/freetds/lib", + "/opt/homebrew/opt/unixodbc/lib", + "/usr/local/opt/unixodbc/lib", + ] { if std::path::Path::new(candidate).exists() { paths.push(candidate.to_string()); } diff --git a/tests/codegen/support/runner.rs b/tests/codegen/support/runner.rs index 4dde24369d..0cb13c7b48 100644 --- a/tests/codegen/support/runner.rs +++ b/tests/codegen/support/runner.rs @@ -20,6 +20,46 @@ use std::time::{Duration, Instant}; use super::*; +/// Reports whether codegen fixtures should build the official CUBRID CCI profile. +fn pdo_cubrid_enabled() -> bool { + cfg!(feature = "pdo-cubrid") || std::env::var_os("ELEPHC_PDO_CUBRID").is_some() +} + +/// Reports whether codegen fixtures should build and link the FreeTDS PDO profile. +fn pdo_dblib_enabled() -> bool { + cfg!(feature = "pdo-dblib") || std::env::var_os("ELEPHC_PDO_DBLIB").is_some() +} + +/// Reports whether codegen fixtures should build the pure-Rust Firebird PDO profile. +fn pdo_firebird_enabled() -> bool { + cfg!(feature = "pdo-firebird") || std::env::var_os("ELEPHC_PDO_FIREBIRD").is_some() +} + +/// Reports whether codegen fixtures should build and link the system ODBC profile. +fn pdo_odbc_enabled() -> bool { + cfg!(feature = "pdo-odbc") || std::env::var_os("ELEPHC_PDO_ODBC").is_some() +} + +/// Reports whether codegen fixtures should build the Informix CLI/ODBC profile. +fn pdo_informix_enabled() -> bool { + cfg!(feature = "pdo-informix") || std::env::var_os("ELEPHC_PDO_INFORMIX").is_some() +} + +/// Reports whether codegen fixtures should build the IBM Db2 CLI/ODBC profile. +fn pdo_ibm_enabled() -> bool { + cfg!(feature = "pdo-ibm") || std::env::var_os("ELEPHC_PDO_IBM").is_some() +} + +/// Reports whether codegen fixtures should build Microsoft PDO_SQLSRV. +fn pdo_sqlsrv_enabled() -> bool { + cfg!(feature = "pdo-sqlsrv") || std::env::var_os("ELEPHC_PDO_SQLSRV").is_some() +} + +/// Reports whether codegen fixtures should build the Oracle Instant Client profile. +fn pdo_oci_enabled() -> bool { + cfg!(feature = "pdo-oci") || std::env::var_os("ELEPHC_PDO_OCI").is_some() +} + /// Describes a Rust bridge staticlib needed by codegen integration fixtures. struct TestBridgeStaticlib { /// Linker library name requested by the compiled program. @@ -213,12 +253,74 @@ fn ensure_bridge_staticlibs(actual_link_libs: &[&str], bridge_staticlib_dir: &Pa .expect("bridge staticlib build lock poisoned"); for bridge in requested_bridge_staticlibs(actual_link_libs) { let archive_path = bridge_staticlib_dir.join(format!("lib{}.a", bridge.lib_name)); - if !bridge_staticlib_needs_build(&archive_path, bridge.package) { + let requires_libpq_profile = bridge.lib_name == "elephc_pdo" + && std::env::var_os("ELEPHC_PDO_LIBPQ").is_some() + && LIBPQ_BRIDGE_BUILT.get().is_none(); + let requires_dblib_profile = bridge.lib_name == "elephc_pdo" + && pdo_dblib_enabled() + && DBLIB_BRIDGE_BUILT.get().is_none(); + let requires_firebird_profile = bridge.lib_name == "elephc_pdo" + && pdo_firebird_enabled() + && FIREBIRD_BRIDGE_BUILT.get().is_none(); + let requires_odbc_profile = bridge.lib_name == "elephc_pdo" + && (pdo_odbc_enabled() + || pdo_informix_enabled() + || pdo_ibm_enabled() + || pdo_sqlsrv_enabled()) + && ODBC_BRIDGE_BUILT.get().is_none(); + let requires_oci_profile = bridge.lib_name == "elephc_pdo" + && pdo_oci_enabled() + && OCI_BRIDGE_BUILT.get().is_none(); + let requires_cubrid_profile = bridge.lib_name == "elephc_pdo" + && pdo_cubrid_enabled() + && CUBRID_BRIDGE_BUILT.get().is_none(); + if !requires_libpq_profile + && !requires_dblib_profile + && !requires_firebird_profile + && !requires_odbc_profile + && !requires_oci_profile + && !requires_cubrid_profile + && !bridge_staticlib_needs_build(&archive_path, bridge.package) + { continue; } - let status = Command::new("cargo") - .args(["build", "-p", bridge.package]) + let mut command = Command::new("cargo"); + command.args(["build", "-p", bridge.package]); + if bridge.lib_name == "elephc_pdo" { + let mut features = Vec::new(); + if std::env::var_os("ELEPHC_PDO_LIBPQ").is_some() { + features.push("libpq-gss"); + } + if pdo_dblib_enabled() { + features.push("dblib"); + } + if pdo_firebird_enabled() { + features.push("firebird"); + } + if pdo_odbc_enabled() { + features.push("odbc"); + } + if pdo_informix_enabled() { + features.push("informix"); + } + if pdo_ibm_enabled() { + features.push("ibm"); + } + if pdo_sqlsrv_enabled() { + features.push("sqlsrv"); + } + if pdo_oci_enabled() { + features.push("oci"); + } + if pdo_cubrid_enabled() { + features.push("cubrid"); + } + if !features.is_empty() { + command.args(["--features", &features.join(",")]); + } + } + let status = command .current_dir(env!("CARGO_MANIFEST_DIR")) .status() .unwrap_or_else(|err| { @@ -238,6 +340,24 @@ fn ensure_bridge_staticlibs(actual_link_libs: &[&str], bridge_staticlib_dir: &Pa bridge.package, archive_path.display() ); + if requires_libpq_profile { + let _ = LIBPQ_BRIDGE_BUILT.set(()); + } + if requires_dblib_profile { + let _ = DBLIB_BRIDGE_BUILT.set(()); + } + if requires_firebird_profile { + let _ = FIREBIRD_BRIDGE_BUILT.set(()); + } + if requires_odbc_profile { + let _ = ODBC_BRIDGE_BUILT.set(()); + } + if requires_oci_profile { + let _ = OCI_BRIDGE_BUILT.set(()); + } + if requires_cubrid_profile { + let _ = CUBRID_BRIDGE_BUILT.set(()); + } } } @@ -399,7 +519,8 @@ fn source_tree_newer_than(dir: &Path, archive_mtime: std::time::SystemTime) -> b /// Links a user object file and a runtime object into a final native binary. /// On macOS uses `ld` with SDK/platform_version flags; on Linux uses `gcc` with -/// static linking when no extra libs are needed. Adds `-lm -lpthread` on Linux. +/// static linking when no extra libs are needed. Linux links each selected PDO +/// system client after the bridge archive, followed by the common runtime libs. pub(crate) fn link_binary( obj_path: &Path, runtime_obj: &Path, @@ -422,6 +543,12 @@ pub(crate) fn link_binary( if needs_bridge_staticlib { ensure_bridge_staticlibs(&actual_link_libs, &bridge_staticlib_dir); } + let needs_libpq = actual_link_libs.iter().any(|lib| *lib == "elephc_pdo") + && std::env::var_os("ELEPHC_PDO_LIBPQ").is_some(); + let needs_dblib = actual_link_libs.iter().any(|lib| *lib == "elephc_pdo") + && pdo_dblib_enabled(); + let needs_odbc = actual_link_libs.iter().any(|lib| *lib == "elephc_pdo") + && (pdo_odbc_enabled() || pdo_informix_enabled() || pdo_ibm_enabled() || pdo_sqlsrv_enabled()); match target().platform { Platform::MacOS => { @@ -430,6 +557,23 @@ pub(crate) fn link_binary( ld_cmd.arg(bin_path); ld_cmd.arg(obj_path); ld_cmd.arg(runtime_obj); + // Resolve FreeTDS's `dbopen` before libSystem's Berkeley DB symbol. + if needs_dblib { + for path in ["/opt/homebrew/opt/freetds/lib", "/usr/local/opt/freetds/lib"] { + if Path::new(path).exists() { + ld_cmd.arg(format!("-L{path}")); + } + } + ld_cmd.arg("-lsybdb"); + } + if needs_odbc { + for path in ["/opt/homebrew/opt/unixodbc/lib", "/usr/local/opt/unixodbc/lib"] { + if Path::new(path).exists() { + ld_cmd.arg(format!("-L{path}")); + } + } + ld_cmd.arg("-lodbc"); + } ld_cmd.args(["-lSystem", "-syslibroot"]); ld_cmd.arg(get_sdk_path()); ld_cmd.args([ @@ -443,6 +587,9 @@ pub(crate) fn link_binary( } append_test_search_paths(&mut ld_cmd, &plan); append_test_link_inputs(&mut ld_cmd, &plan, Platform::MacOS); + if needs_libpq { + ld_cmd.arg("-lpq"); + } append_test_frameworks(&mut ld_cmd, &plan); // The PostgreSQL driver in the PDO bridge pulls in `whoami`, which // references CoreFoundation / SystemConfiguration on macOS. @@ -473,6 +620,15 @@ pub(crate) fn link_binary( } append_test_search_paths(&mut ld_cmd, &plan); append_test_link_inputs(&mut ld_cmd, &plan, Platform::Linux); + if needs_libpq { + ld_cmd.arg("-lpq"); + } + if needs_dblib { + ld_cmd.arg("-lsybdb"); + } + if needs_odbc { + ld_cmd.arg("-lodbc"); + } if !actual_link_libs.is_empty() { ld_cmd.arg("-Wl,--as-needed"); } @@ -637,6 +793,16 @@ fn append_test_frameworks(command: &mut Command, plan: &elephc::link_plan::LinkP /// On other platform/arch combinations, execs the binary natively. /// Used for post-link execution of already-assembled test binaries. pub(crate) fn run_binary(bin_path: &Path, dir: &Path) -> Output { + run_binary_with_env(bin_path, dir, &[]) +} + +/// Runs a compiled binary with isolated environment overrides, using qemu for +/// cross-architecture Linux AArch64 fixtures when required. +pub(crate) fn run_binary_with_env( + bin_path: &Path, + dir: &Path, + env: &[(&str, &std::ffi::OsStr)], +) -> Output { if target().platform == Platform::Linux && target().arch == Arch::AArch64 && cfg!(target_arch = "x86_64") @@ -645,11 +811,11 @@ pub(crate) fn run_binary(bin_path: &Path, dir: &Path) -> Output { if let Some(sysroot) = qemu_sysroot() { cmd.args(["-L", sysroot]); } - cmd.arg(bin_path).current_dir(dir); + cmd.arg(bin_path).current_dir(dir).envs(env.iter().copied()); run_command_with_timeout(cmd) } else { let mut cmd = Command::new(bin_path); - cmd.current_dir(dir); + cmd.current_dir(dir).envs(env.iter().copied()); run_command_with_timeout(cmd) } } @@ -748,13 +914,47 @@ pub(crate) fn assemble_and_run( let output = run_binary(&bin_path, dir); assert!( output.status.success(), - "binary exited with error: {}", + "binary exited with status {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap() } +/// Assembles, links, and runs a happy-path fixture with per-process environment +/// overrides, returning its UTF-8 stdout. +pub(crate) fn assemble_and_run_with_env( + user_asm: &str, + runtime_obj: &Path, + dir: &Path, + requirements: &TestLinkRequirements, + extra_link_paths: &[String], + extra_frameworks: &[String], + env: &[(&str, &std::ffi::OsStr)], +) -> String { + let obj_path = dir.join("test.o"); + let bin_path = dir.join("test"); + + assemble_from_stdin(user_asm, &obj_path); + link_binary( + &obj_path, + runtime_obj, + &bin_path, + requirements, + extra_link_paths, + extra_frameworks, + ); + let output = run_binary_with_env(&bin_path, dir, env); + assert!( + output.status.success(), + "binary exited with error: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + // Captures stdout and stderr from a compiled binary, along with its exit status. // Used by tests that need to inspect both output streams without asserting success, // or by error/regression tests that need to validate stderr without requiring exit failure. diff --git a/tests/error_tests/classes_traits.rs b/tests/error_tests/classes_traits.rs index 0e8d46aff9..4058b5b759 100644 --- a/tests/error_tests/classes_traits.rs +++ b/tests/error_tests/classes_traits.rs @@ -9,6 +9,25 @@ use super::*; +/// Runs the frontend with PDO prelude injection and asserts its first diagnostic. +fn expect_pdo_error(src: &str, expected_substr: &str) { + let tokens = tokenize(src).expect("PDO diagnostic fixture must tokenize"); + let ast = parse(&tokens).expect("PDO diagnostic fixture must parse"); + let ast = elephc::autoload::collect_aliases(ast); + let ast = elephc::pdo_prelude::inject_if_used(ast, false); + let ast = elephc::name_resolver::resolve(ast).expect("PDO fixture names must resolve"); + let ast = elephc::optimize::fold_constants(ast); + let message = types::check(&ast) + .expect_err("PDO diagnostic fixture must fail") + .message; + assert!( + message.contains(expected_substr), + "Error '{}' doesn't contain '{}'", + message, + expected_substr + ); +} + /// Verifies that `instanceof parent` reports "Class has no parent class" when the class /// has no parent. #[test] @@ -738,3 +757,21 @@ fn test_error_nullsafe_dynamic_method_call_named_arguments() { "Named arguments are not supported in dynamic calls", ); } + +/// Verifies user code cannot invoke the compiler-private PDORow constructor. +#[test] +fn test_error_pdo_row_constructor_is_private() { + expect_pdo_error( + " = Vec::new(); - let outputs: Vec<(String, String)> = PhpVersion::ALL + let outputs: Vec<(String, String)> = PhpVersion::MAINTAINED .iter() .map(|profile| { let spelling = format!("{}.{}", profile.major(), profile.minor()); diff --git a/tests/php_profile_session_independence_tests.rs b/tests/php_profile_session_independence_tests.rs index 7f33d993bb..a07415e5ec 100644 --- a/tests/php_profile_session_independence_tests.rs +++ b/tests/php_profile_session_independence_tests.rs @@ -231,7 +231,7 @@ fn check_probe(name: &str) { let dir = make_test_dir(&format!("elephc_sess_{}", case.name)); let predicted_independent = table_predicts_independent(case.source); - let bodies: Vec<(String, String)> = PhpVersion::ALL + let bodies: Vec<(String, String)> = PhpVersion::MAINTAINED .iter() .map(|profile| { let spelling = profile.spelling().to_string(); diff --git a/tests/web_tests.rs b/tests/web_tests.rs index 9812b4dc04..c1225adc18 100644 --- a/tests/web_tests.rs +++ b/tests/web_tests.rs @@ -85,13 +85,95 @@ fn wait_until_ready(addr: &str) { panic!("server did not start listening on {}", addr); } -/// Spawns the server binary on `addr`, waits until it accepts connections. -fn spawn_server(bin: &Path, addr: &str, workers: &str) -> std::process::Child { - let child = Command::new(bin) - .arg("--listen").arg(addr) - .arg("--workers").arg(workers) - .spawn() - .expect("failed to spawn web server"); +/// RAII guard around a spawned web-server child process. +/// +/// `std::process::Child` does *not* kill the process when its handle is +/// dropped, so any test that panics before its manual `child.kill()` — a failed +/// assertion, or an `unwrap` in `http_get`/`http_request`/`wait_until_ready` — +/// leaks a resident server (plus its prefork workers, which stay alive while the +/// master does). Under load that accumulation exhausts memory and triggers the +/// OS OOM killer. Wrapping the child in this guard makes `Drop` reap it +/// unconditionally, even while unwinding, so a failing test can never leak a +/// server. Killing the master reaps its workers (verified: they exit on parent +/// death), so the guard only needs to kill the master. +struct ServerGuard { + child: std::process::Child, +} + +impl ServerGuard { + /// Wraps an already-spawned server child so it is reaped on scope exit. + fn new(child: std::process::Child) -> Self { + Self { child } + } + + /// Terminates the server gracefully so its prefork workers are reaped too. + /// + /// `std::process::Child::kill` sends `SIGKILL`, which the master cannot + /// trap — so its worker children are reparented to `launchd`/`init` and + /// survive as orphans (verified: `SIGKILL` on the master leaves the workers + /// running). Across a suite that spawns dozens of servers those orphans + /// accumulate and exhaust memory. Sending `SIGTERM` first lets the master + /// run its shutdown path and reap its own workers; a `SIGKILL` fallback + /// covers a wedged master after a short grace period. This inherent method + /// shadows `Child::kill` through the `Deref`, so every existing + /// `child.kill()` call site becomes graceful with no change. Idempotent: an + /// already-exited child returns `Ok` immediately. + fn kill(&mut self) -> std::io::Result<()> { + if matches!(self.child.try_wait(), Ok(Some(_))) { + return Ok(()); + } + let pid = self.child.id().to_string(); + let _ = Command::new("kill").arg("-TERM").arg(&pid).status(); + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + if matches!(self.child.try_wait(), Ok(Some(_))) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(20)); + } + // Wedged master: force-kill. Its workers may briefly orphan, but this + // path is the rare exception, not the steady-state teardown. + self.child.kill() + } +} + +impl std::ops::Deref for ServerGuard { + type Target = std::process::Child; + /// Exposes the wrapped child for read-only access (`id`, `stdout`). + fn deref(&self) -> &std::process::Child { + &self.child + } +} + +impl std::ops::DerefMut for ServerGuard { + /// Exposes the wrapped child for `kill`/`wait`/`try_wait`/`stdout.take()`. + fn deref_mut(&mut self) -> &mut std::process::Child { + &mut self.child + } +} + +impl Drop for ServerGuard { + /// Gracefully terminates and reaps the server unconditionally, even during a + /// panic unwind, so neither the master nor its workers leak. Best-effort: an + /// already-reaped child is a no-op. + fn drop(&mut self) { + let _ = self.kill(); + let _ = self.child.wait(); + } +} + +/// Spawns the server binary on `addr`, waits until it accepts connections, and +/// returns an RAII [`ServerGuard`] that reaps it on scope exit. The child is +/// wrapped in the guard *before* `wait_until_ready`, so a readiness-timeout +/// panic still reaps the process instead of orphaning it. +fn spawn_server(bin: &Path, addr: &str, workers: &str) -> ServerGuard { + let child = ServerGuard::new( + Command::new(bin) + .arg("--listen").arg(addr) + .arg("--workers").arg(workers) + .spawn() + .expect("failed to spawn web server"), + ); wait_until_ready(addr); child } @@ -646,10 +728,12 @@ fn web_body_size_limit_returns_413() { let bin = compile_web(&dir, src, "app"); let port = free_port(); let addr = format!("127.0.0.1:{}", port); - let mut child = Command::new(&bin) - .args(["--listen", &addr, "--workers", "1", "--max-body-size", "64"]) - .spawn() - .expect("spawn"); + let mut child = ServerGuard::new( + Command::new(&bin) + .args(["--listen", &addr, "--workers", "1", "--max-body-size", "64"]) + .spawn() + .expect("spawn"), + ); wait_until_ready(&addr); let small = http_request(&addr, "POST", "/", &[("Content-Type", "text/plain")], &"x".repeat(10)); let big = http_request(&addr, "POST", "/", &[("Content-Type", "text/plain")], &"x".repeat(1000)); @@ -844,11 +928,13 @@ fn web_env_superglobal_populated() { let bin = compile_web(&dir, src, "app"); let port = free_port(); let addr = format!("127.0.0.1:{}", port); - let mut child = Command::new(&bin) - .args(["--listen", &addr, "--workers", "1"]) - .env("ELEPHC_WEB_TEST_ENV", "present") - .spawn() - .expect("spawn"); + let mut child = ServerGuard::new( + Command::new(&bin) + .args(["--listen", &addr, "--workers", "1"]) + .env("ELEPHC_WEB_TEST_ENV", "present") + .spawn() + .expect("spawn"), + ); wait_until_ready(&addr); let resp = http_request(&addr, "GET", "/", &[], ""); let _ = child.kill(); @@ -886,10 +972,12 @@ fn web_max_requests_recycles_and_keeps_serving() { let bin = compile_web(&dir, ", +} + + +fn main() -> std::result::Result<(), Box> { + let url = "mysql://root:password@localhost:3307/db_name"; + # Opts::try_from(url)?; + # let url = get_opts(); + let pool = Pool::new(url)?; + + let mut conn = pool.get_conn()?; + + // Let's create a table for payments. + conn.query_drop( + r"CREATE TEMPORARY TABLE payment ( + customer_id int not null, + amount int not null, + account_name text + )")?; + + let payments = vec![ + Payment { customer_id: 1, amount: 2, account_name: None }, + Payment { customer_id: 3, amount: 4, account_name: Some("foo".into()) }, + Payment { customer_id: 5, amount: 6, account_name: None }, + Payment { customer_id: 7, amount: 8, account_name: None }, + Payment { customer_id: 9, amount: 10, account_name: Some("bar".into()) }, + ]; + + // Now let's insert payments to the database + conn.exec_batch( + r"INSERT INTO payment (customer_id, amount, account_name) + VALUES (:customer_id, :amount, :account_name)", + payments.iter().map(|p| params! { + "customer_id" => p.customer_id, + "amount" => p.amount, + "account_name" => &p.account_name, + }) + )?; + + // Let's select payments from database. Type inference should do the trick here. + let selected_payments = conn + .query_map( + "SELECT customer_id, amount, account_name from payment", + |(customer_id, amount, account_name)| { + Payment { customer_id, amount, account_name } + }, + )?; + + // Let's make sure, that `payments` equals to `selected_payments`. + // Mysql gives no guaranties on order of returned rows + // without `ORDER BY`, so assume we are lucky. + assert_eq!(payments, selected_payments); + println!("Yay!"); + + Ok(()) +} +``` + +### Crate Features + +* feature sets: + + * **default** – includes `buffer-pool` `flate2/zlib` and `derive` + * **default-rust** - same as `default` but with `flate2/rust_backend` instead of `flate2/zlib` + * **minimal** - includes `flate2/zlib` only + * **minimal-rust** - includes `flate2/rust_backend` only + +* features: + * **buffer-pool** – enables buffer pooling + (see the [Buffer Pool](#buffer-pool) section) + * **derive** – reexports derive macros under `prelude` + (see [corresponding section][derive_docs] in the `mysql_common` documentation) + +* TLS/SSL related features: + + * **native-tls** – specifies `native-tls` as the TLS backend + (see the [SSL Support](#ssl-support) section) + * **rustls-tls** – specifies `rustls` as the TLS backend using `aws-lc-rs` crypto provider + (see the [SSL Support](#ssl-support) section) + * **rustls-tls-ring** – specifies `rustls` as the TLS backend using `ring` crypto provider + (see the [SSL Support](#ssl-support) section) + * **rustls** - specifies `rustls` as the TLS backend without crypto provider + (see the [SSL Support](#ssl-support) section) + +* features proxied from `mysql_common`: + + * **derive** - see [this table][common_features]. + * **chrono** - see [this table][common_features]. + * **time** - see [this table][common_features]. + * **bigdecimal** - see [this table][common_features]. + * **rust_decimal** - see [this table][common_features]. + * **frunk** - see [this table][common_features]. + * **binlog** - see [this table][common_features]. + +Please note, that you'll need to reenable required features if you are using `default-features = false`: + +```toml +[dependencies] +# Lets say that we want to use only the `rustls-tls` feature: +mysql = { version = "*", default-features = false, features = ["minimal-rust", "rustls-tls"] } +``` + +### API Documentation + +Please refer to the [crate docs]. + +### Basic structures + +#### `Opts` + +This structure holds server host name, client username/password and other settings, +that controls client behavior. + +##### URL-based connection string + +Note, that you can use URL-based connection string as a source of an `Opts` instance. +URL schema must be `mysql`. Host, port and credentials, as well as query parameters, +should be given in accordance with the RFC 3986. + +Examples: + +```rust +let _ = Opts::from_url("mysql://localhost/some_db")?; +let _ = Opts::from_url("mysql://[::1]/some_db")?; +let _ = Opts::from_url("mysql://user:pass%20word@127.0.0.1:3307/some_db?")?; +``` + +Supported URL parameters (for the meaning of each field please refer to the docs on `Opts` +structure in the create API docs): + +* `user: string` – MySql client user name +* `password: string` – MySql client password; +* `db_name: string` – MySql database name; +* `host: Host` – MySql server hostname/ip; +* `port: u16` – MySql server port; +* `pool_min: usize` – see [`PoolConstraints::min`]; +* `pool_max: usize` – see [`PoolConstraints::max`]; +* `prefer_socket: true | false` - see [`Opts::get_prefer_socket`]; +* `tcp_keepalive_time_ms: u32` - defines the value (in milliseconds) + of the `tcp_keepalive_time` field in the `Opts` structure; +* `tcp_keepalive_probe_interval_secs: u32` - defines the value + of the `tcp_keepalive_probe_interval_secs` field in the `Opts` structure; +* `tcp_keepalive_probe_count: u32` - defines the value + of the `tcp_keepalive_probe_count` field in the `Opts` structure; +* `tcp_connect_timeout_ms: u64` - defines the value (in milliseconds) + of the `tcp_connect_timeout` field in the `Opts` structure; +* `tcp_user_timeout_ms` - defines the value (in milliseconds) + of the `tcp_user_timeout` field in the `Opts` structure; +* `stmt_cache_size: u32` - defines the value of the same field in the `Opts` structure; +* `enable_cleartext_plugin` – see [`Opts::get_enable_cleartext_plugin`]; +* `secure_auth` – see [`Opts::get_secure_auth`]; +* `reset_connection` – see [`PoolOpts::reset_connection`]; +* `check_health` – see [`PoolOpts::check_health`]; +* `compress` - defines the value of the same field in the `Opts` structure. + Supported value are: + * `true` - enables compression with the default compression level; + * `fast` - enables compression with "fast" compression level; + * `best` - enables compression with "best" compression level; + * `1`..`9` - enables compression with the given compression level. +* `socket` - socket path on UNIX, or pipe name on Windows. + +#### `OptsBuilder` + +It's a convenient builder for the `Opts` structure. It defines setters for fields +of the `Opts` structure. + +```rust +let opts = OptsBuilder::new() + .user(Some("foo")) + .db_name(Some("bar")); +let _ = Conn::new(opts)?; +``` + +#### `Conn` + +This structure represents an active MySql connection. It also holds statement cache +and metadata for the last result set. + +Conn's destructor will gracefully disconnect it from the server. + +#### `Transaction` + +It's a simple wrapper on top of a routine, that starts with `START TRANSACTION` +and ends with `COMMIT` or `ROLLBACK`. + +```rust +use mysql::*; +use mysql::prelude::*; + +let pool = Pool::new(get_opts())?; +let mut conn = pool.get_conn()?; + +let mut tx = conn.start_transaction(TxOpts::default())?; +tx.query_drop("CREATE TEMPORARY TABLE tmp (TEXT a)")?; +tx.exec_drop("INSERT INTO tmp (a) VALUES (?)", ("foo",))?; +let val: Option = tx.query_first("SELECT a from tmp")?; +assert_eq!(val.unwrap(), "foo"); +// Note, that transaction will be rolled back implicitly on Drop, if not committed. +tx.rollback(); + +let val: Option = conn.query_first("SELECT a from tmp")?; +assert_eq!(val, None); +``` + +#### `Pool` + +It's a reference to a connection pool, that can be cloned and shared between threads. + +```rust +use mysql::*; +use mysql::prelude::*; + +use std::thread::spawn; + +let pool = Pool::new(get_opts())?; + +let handles = (0..4).map(|i| { + spawn({ + let pool = pool.clone(); + move || { + let mut conn = pool.get_conn()?; + conn.exec_first::("SELECT ? * 10", (i,)) + .map(Option::unwrap) + } + }) +}); + +let result: Result> = handles.map(|handle| handle.join().unwrap()).collect(); + +assert_eq!(result.unwrap(), vec![0, 10, 20, 30]); +``` + +#### `Statement` + +Statement, actually, is just an identifier coupled with statement metadata, i.e an information +about its parameters and columns. Internally the `Statement` structure also holds additional +data required to support named parameters (see bellow). + +```rust +use mysql::*; +use mysql::prelude::*; + +let pool = Pool::new(get_opts())?; +let mut conn = pool.get_conn()?; + +let stmt = conn.prep("DO ?")?; + +// The prepared statement will return no columns. +assert!(stmt.columns().is_empty()); + +// The prepared statement have one parameter. +let param = stmt.params().get(0).unwrap(); +assert_eq!(param.schema_str(), ""); +assert_eq!(param.table_str(), ""); +assert_eq!(param.name_str(), "?"); +``` + +#### `Value` + +This enumeration represents the raw value of a MySql cell. Library offers conversion between +`Value` and different rust types via `FromValue` trait described below. + +##### `FromValue` trait + +This trait is reexported from **mysql_common** create. Please refer to its +[crate docs][mysql_common docs] for the list of supported conversions. + +Trait offers conversion in two flavours: + +* `from_value(Value) -> T` - convenient, but panicking conversion. + + Note, that for any variant of `Value` there exist a type, that fully covers its domain, + i.e. for any variant of `Value` there exist `T: FromValue` such that `from_value` will never + panic. This means, that if your database schema is known, then it's possible to write your + application using only `from_value` with no fear of runtime panic. + +* `from_value_opt(Value) -> Option` - non-panicking, but less convenient conversion. + + This function is useful to probe conversion in cases, where source database schema + is unknown. + +```rust +use mysql::*; +use mysql::prelude::*; + +let via_test_protocol: u32 = from_value(Value::Bytes(b"65536".to_vec())); +let via_bin_protocol: u32 = from_value(Value::UInt(65536)); +assert_eq!(via_test_protocol, via_bin_protocol); + +let unknown_val = // ... + +// Maybe it is a float? +let unknown_val = match from_value_opt::(unknown_val) { + Ok(float) => { + println!("A float value: {}", float); + return Ok(()); + } + Err(FromValueError(unknown_val)) => unknown_val, +}; + +// Or a string? +let unknown_val = match from_value_opt::(unknown_val) { + Ok(string) => { + println!("A string value: {}", string); + return Ok(()); + } + Err(FromValueError(unknown_val)) => unknown_val, +}; + +// Screw this, I'll simply match on it +match unknown_val { + val @ Value::NULL => { + println!("An empty value: {:?}", from_value::>(val)) + }, + val @ Value::Bytes(..) => { + // It's non-utf8 bytes, since we already tried to convert it to String + println!("Bytes: {:?}", from_value::>(val)) + } + val @ Value::Int(..) => { + println!("A signed integer: {}", from_value::(val)) + } + val @ Value::UInt(..) => { + println!("An unsigned integer: {}", from_value::(val)) + } + Value::Float(..) => unreachable!("already tried"), + val @ Value::Double(..) => { + println!("A double precision float value: {}", from_value::(val)) + } + val @ Value::Date(..) => { + use time::PrimitiveDateTime; + println!("A date value: {}", from_value::(val)) + } + val @ Value::Time(..) => { + use std::time::Duration; + println!("A time value: {:?}", from_value::(val)) + } +} +``` + +#### `Row` + +Internally `Row` is a vector of `Value`s, that also allows indexing by a column name/offset, +and stores row metadata. Library offers conversion between `Row` and sequences of Rust types +via `FromRow` trait described below. + +##### `FromRow` trait + +This trait is reexported from **mysql_common** create. Please refer to its +[crate docs][mysql_common docs] for the list of supported conversions. + +This conversion is based on the `FromValue` and so comes in two similar flavours: + +* `from_row(Row) -> T` - same as `from_value`, but for rows; +* `from_row_opt(Row) -> Option` - same as `from_value_opt`, but for rows. + +[`Queryable`](#queryable) +trait offers implicit conversion for rows of a query result, +that is based on this trait. + +```rust +use mysql::*; +use mysql::prelude::*; + +let mut conn = Conn::new(get_opts())?; + +// Single-column row can be converted to a singular value: +let val: Option = conn.query_first("SELECT 'foo'")?; +assert_eq!(val.unwrap(), "foo"); + +// Example of a multi-column row conversion to an inferred type: +let row = conn.query_first("SELECT 255, 256")?; +assert_eq!(row, Some((255u8, 256u16))); + +// The FromRow trait does not support to-tuple conversion for rows with more than 12 columns, +// but you can do this by hand using row indexing or `Row::take` method: +let row: Row = conn.exec_first("select 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12", ())?.unwrap(); +for i in 0..row.len() { + assert_eq!(row[i], Value::Int(i as i64)); +} + +// Another way to handle wide rows is to use HList (requires `mysql_common/frunk` feature) +use frunk::{HList, hlist, hlist_pat}; +let query = "select 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15"; +type RowType = HList!(u8, u16, u32, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8); +let first_three_columns = conn.query_map(query, |row: RowType| { + // do something with the row (see the `frunk` crate documentation) + let hlist_pat![c1, c2, c3, ...] = row; + (c1, c2, c3) +}); +assert_eq!(first_three_columns.unwrap(), vec![(0_u8, 1_u16, 2_u32)]); + +// Some unknown row +let row: Row = conn.query_first( + // ... + # "SELECT 255, Null", +)?.unwrap(); + +for column in row.columns_ref() { + // Cells in a row can be indexed by numeric index or by column name + let column_value = &row[column.name_str().as_ref()]; + + println!( + "Column {} of type {:?} with value {:?}", + column.name_str(), + column.column_type(), + column_value, + ); +} +``` + +#### `Params` + +Represents parameters of a prepared statement, but this type won't appear directly in your code +because binary protocol API will ask for `T: Into`, where `Into` is implemented: + +* for tuples of `Into` types up to arity 12; + + **Note:** singular tuple requires extra comma, e.g. `("foo",)`; + +* for `IntoIterator>` for cases, when your statement takes more + than 12 parameters; +* for named parameters representation (the value of the `params!` macro, described below). + +```rust +use mysql::*; +use mysql::prelude::*; + +let mut conn = Conn::new(get_opts())?; + +// Singular tuple requires extra comma: +let row: Option = conn.exec_first("SELECT ?", (0,))?; +assert_eq!(row.unwrap(), 0); + +// More than 12 parameters: +let row: Option = conn.exec_first( + "SELECT CONVERT(? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ?, UNSIGNED)", + (0..16).collect::>(), +)?; +assert_eq!(row.unwrap(), 120); +``` + +**Note:** Please refer to the [**mysql_common** crate docs][mysql_common docs] for the list +of types, that implements `Into`. + +##### `Serialized`, `Deserialized` + +Wrapper structures for cases, when you need to provide a value for a JSON cell, +or when you need to parse JSON cell as a struct. + +```rust +use mysql::*; +use mysql::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Serializable structure. +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct Example { + foo: u32, +} + +// Value::from for Serialized will emit json string. +let value = Value::from(Serialized(Example { foo: 42 })); +assert_eq!(value, Value::Bytes(br#"{"foo":42}"#.to_vec())); + +// from_value for Deserialized will parse json string. +let structure: Deserialized = from_value(value); +assert_eq!(structure, Deserialized(Example { foo: 42 })); +``` + +#### [`QueryResult`] + +It's an iterator over rows of a query result with support of multi-result sets. It's intended +for cases when you need full control during result set iteration. For other cases +[`Queryable`](#queryable) provides a set of methods that will immediately consume +the first result set and drop everything else. + +This iterator is lazy so it won't read the result from server until you iterate over it. +MySql protocol is strictly sequential, so `Conn` will be mutably borrowed until the result +is fully consumed (please also look at [`QueryResult::iter`] docs). + +```rust +use mysql::*; +use mysql::prelude::*; + +let mut conn = Conn::new(get_opts())?; + +// This query will emit two result sets. +let mut result = conn.query_iter("SELECT 1, 2; SELECT 3, 3.14;")?; + +let mut sets = 0; +while let Some(result_set) = result.iter() { + sets += 1; + + println!("Result set columns: {:?}", result_set.columns()); + println!( + "Result set meta: {}, {:?}, {} {}", + result_set.affected_rows(), + result_set.last_insert_id(), + result_set.warnings(), + result_set.info_str(), + ); + + for row in result_set { + match sets { + 1 => { + // First result set will contain two numbers. + assert_eq!((1_u8, 2_u8), from_row(row?)); + } + 2 => { + // Second result set will contain a number and a float. + assert_eq!((3_u8, 3.14), from_row(row?)); + } + _ => unreachable!(), + } + } +} + +assert_eq!(sets, 2); +``` + +### Text protocol + +MySql text protocol is implemented in the set of `Queryable::query*` methods. It's useful when your +query doesn't have parameters. + +**Note:** All values of a text protocol result set will be encoded as strings by the server, +so `from_value` conversion may lead to additional parsing costs. + +Examples: + +```rust +let pool = Pool::new(get_opts())?; +let val = pool.get_conn()?.query_first("SELECT POW(2, 16)")?; + +// Text protocol returns bytes even though the result of POW +// is actually a floating point number. +assert_eq!(val, Some(Value::Bytes("65536".as_bytes().to_vec()))); +``` + +#### The `TextQuery` trait. + +The `TextQuery` trait covers the set of `Queryable::query*` methods from the perspective +of a query, i.e. `TextQuery` is something, that can be performed if suitable connection +is given. Suitable connections are: + +* `&Pool` +* `Conn` +* `PooledConn` +* `&mut Conn` +* `&mut PooledConn` +* `&mut Transaction` + +The unique characteristic of this trait, is that you can give away the connection +and thus produce `QueryResult` that satisfies `'static`: + +```rust +use mysql::*; +use mysql::prelude::*; + +fn iter(pool: &Pool) -> Result>> { + let result = "SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3".run(pool)?; + Ok(result.map(|row| row.map(from_row))) +} + +let pool = Pool::new(get_opts())?; + +let it = iter(&pool)?; + +assert_eq!(it.collect::>>()?, vec![1, 2, 3]); +``` + +### Binary protocol and prepared statements. + +MySql binary protocol is implemented in `prep`, `close` and the set of `exec*` methods, +defined on the [`Queryable`](#queryable) trait. Prepared statements is the only way to +pass rust value to the MySql server. MySql uses `?` symbol as a parameter placeholder +and it's only possible to use parameters where a single MySql value is expected. +For example: + +```rust +let pool = Pool::new(get_opts())?; +let val = pool.get_conn()?.exec_first("SELECT POW(?, ?)", (2, 16))?; + +assert_eq!(val, Some(Value::Double(65536.0))); +``` + +#### Statements + +In MySql each prepared statement belongs to a particular connection and can't be executed +on another connection. Trying to do so will lead to an error. The driver won't tie statement +to its connection in any way, but one can look on to the connection id, contained + in the `Statement` structure. + +```rust +let pool = Pool::new(get_opts())?; + +let mut conn_1 = pool.get_conn()?; +let mut conn_2 = pool.get_conn()?; + +let stmt_1 = conn_1.prep("SELECT ?")?; + +// stmt_1 is for the conn_1, .. +assert!(stmt_1.connection_id() == conn_1.connection_id()); +assert!(stmt_1.connection_id() != conn_2.connection_id()); + +// .. so stmt_1 will execute only on conn_1 +assert!(conn_1.exec_drop(&stmt_1, ("foo",)).is_ok()); +assert!(conn_2.exec_drop(&stmt_1, ("foo",)).is_err()); +``` + +#### Statement cache + +##### Note + +Statement cache only works for: +1. for raw [`Conn`] +2. for [`PooledConn`]: + * within its lifetime if [`PoolOpts::reset_connection`] is `true` + * within the lifetime of a wrapped [`Conn`] if [`PoolOpts::reset_connection`] is `false` + +##### Description + +`Conn` will manage the cache of prepared statements on the client side, so subsequent calls +to prepare with the same statement won't lead to a client-server roundtrip. Cache size +for each connection is determined by the `stmt_cache_size` field of the `Opts` structure. +Statements, that are out of this boundary will be closed in LRU order. + +Statement cache is completely disabled if `stmt_cache_size` is zero. + +**Caveats:** + +* disabled statement cache means, that you have to close statements yourself using + `Conn::close`, or they'll exhaust server limits/resources; + +* you should be aware of the [`max_prepared_stmt_count`][max_prepared_stmt_count] + option of the MySql server. If the number of active connections times the value + of `stmt_cache_size` is greater, than you could receive an error while preparing + another statement. + +#### Named parameters + +MySql itself doesn't have named parameters support, so it's implemented on the client side. +One should use `:name` as a placeholder syntax for a named parameter. Named parameters uses +the following naming convention: + +* parameter name must start with either `_` or `a..z` +* parameter name may continue with `_`, `a..z` and `0..9` + +Named parameters may be repeated within the statement, e.g `SELECT :foo, :foo` will require +a single named parameter `foo` that will be repeated on the corresponding positions during +statement execution. + +One should use the `params!` macro to build parameters for execution. + +**Note:** Positional and named parameters can't be mixed within the single statement. + +Examples: + +```rust +let pool = Pool::new(get_opts())?; + +let mut conn = pool.get_conn()?; +let stmt = conn.prep("SELECT :foo, :bar, :foo")?; + +let foo = 42; + +let val_13 = conn.exec_first(&stmt, params! { "foo" => 13, "bar" => foo })?.unwrap(); +// Short syntax is available when param name is the same as variable name: +let val_42 = conn.exec_first(&stmt, params! { foo, "bar" => 13 })?.unwrap(); + +assert_eq!((foo, 13, foo), val_42); +assert_eq!((13, foo, 13), val_13); +``` + +#### Buffer pool + +Crate uses the global lock-free buffer pool for the purpose of IO and data serialization/deserialization, +that helps to avoid allocations for basic scenarios. You can control its characteristics using +the following environment variables: + +* `RUST_MYSQL_BUFFER_POOL_CAP` (defaults to 128) – controls the pool capacity. Dropped buffer will + be immediately deallocated if the pool is full. Set it to `0` to disable the pool at runtime. + +* `RUST_MYSQL_BUFFER_SIZE_CAP` (defaults to 4MiB) – controls the maximum capacity of a buffer + stored in the pool. Capacity of a dropped buffer will be shrunk to this value when buffer + is returned to the pool. + +To completely disable the pool (say you are using jemalloc) please remove the `buffer-pool` feature +from the set of default crate features (see the [Crate Features](#crate-features) section). + +#### `BinQuery` and `BatchQuery` traits. + +`BinQuery` and `BatchQuery` traits covers the set of `Queryable::exec*` methods from +the perspective of a query, i.e. `BinQuery` is something, that can be performed if suitable +connection is given (see [`TextQuery`](#the-textquery-trait) section for the list +of suitable connections). + +As with the [`TextQuery`](#the-textquery-trait) you can give away the connection and acquire +`QueryResult` that satisfies `'static`. + +`BinQuery` is for prepared statements, and prepared statements requires a set of parameters, +so `BinQuery` is implemented for `QueryWithParams` structure, that can be acquired, using +`WithParams` trait. + +Example: + +```rust +use mysql::*; +use mysql::prelude::*; + +let pool = Pool::new(get_opts())?; + +let result: Option<(u8, u8, u8)> = "SELECT ?, ?, ?" + .with((1, 2, 3)) // <- WithParams::with will construct an instance of QueryWithParams + .first(&pool)?; // <- QueryWithParams is executed on the given pool + +assert_eq!(result.unwrap(), (1, 2, 3)); +``` + +The `BatchQuery` trait is a helper for batch statement execution. It's implemented for +`QueryWithParams` where parameters is an iterator over parameters: + +```rust +use mysql::*; +use mysql::prelude::*; + +let pool = Pool::new(get_opts())?; +let mut conn = pool.get_conn()?; + +"CREATE TEMPORARY TABLE batch (x INT)".run(&mut conn)?; +"INSERT INTO batch (x) VALUES (?)" + .with((0..3).map(|x| (x,))) // <- QueryWithParams constructed with an iterator + .batch(&mut conn)?; // <- batch execution is preformed here + +let result: Vec = "SELECT x FROM batch".fetch(conn)?; + +assert_eq!(result, vec![0, 1, 2]); +``` + +#### `Queryable` + +The `Queryable` trait defines common methods for `Conn`, `PooledConn` and `Transaction`. +The set of basic methods consts of: + +* `query_iter` - basic methods to execute text query and get `QueryResult`; +* `prep` - basic method to prepare a statement; +* `exec_iter` - basic method to execute statement and get `QueryResult`; +* `close` - basic method to close the statement; + +The trait also defines the set of helper methods, that is based on basic methods. +These methods will consume only the first result set, other result sets will be dropped: + +* `{query|exec}` - to collect the result into a `Vec`; +* `{query|exec}_first` - to get the first `T: FromRow`, if any; +* `{query|exec}_map` - to map each `T: FromRow` to some `U`; +* `{query|exec}_fold` - to fold the set of `T: FromRow` to a single value; +* `{query|exec}_drop` - to immediately drop the result. + +The trait also defines the `exec_batch` function, which is a helper for batch statement +execution. + +### SSL Support + +SSL support comes in two flavors: + +1. Based on the `native-tls` crate – native TLS backend. + + This uses the native OS SSL/TLS provider. Enabled by the **rustls-tls** feature. + +2. Based on the `rustls` – TLS backend written in Rust. You have three options here: + + 1. **rustls-tls** feature enables `rustls` backend with `aws-lc-rs` crypto provider + 2. **rustls-tls-ring** feature enables `rustls` backend with `ring` crypto provider + 3. **rustls** feature enables `rustls` backend without crypto provider — you have to + install your own provider to avoid "no process-level CryptoProvider available" error + (see relevant section of the [`rustls` crate docs](https://docs.rs/rustls)) + + Please also note a few things about **rustls**: + + * it will fail if you'll try to connect to the server by its IP address, hostname is required; + * it, most likely, won't work on windows, at least with default server certs, generated by the + MySql installer. + +[crate docs]: https://docs.rs/mysql +[mysql_common docs]: https://docs.rs/mysql_common +[max_prepared_stmt_count]: https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_max_prepared_stmt_count +[derive_docs]: https://docs.rs/mysql_common/latest/mysql_common/#derive-macros +[common_features]: https://docs.rs/mysql_common/latest/mysql_common/#crate-features + +## Changelog + +Available [here](https://github.com/blackbeam/rust-mysql-simple/releases) + +## License + +Licensed under either of + +* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0) +* MIT license ([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally +submitted for inclusion in the work by you, as defined in the Apache-2.0 +license, shall be dual licensed as above, without any additional terms or +conditions. diff --git a/vendor/mysql-28.0.0/README.tpl b/vendor/mysql-28.0.0/README.tpl new file mode 100644 index 0000000000..b4b3e42bfc --- /dev/null +++ b/vendor/mysql-28.0.0/README.tpl @@ -0,0 +1,28 @@ +[![Gitter](https://badges.gitter.im/rust-mysql/community.svg)](https://gitter.im/rust-mysql/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) + +[![Crates.io](https://img.shields.io/crates/v/mysql.svg)](https://crates.io/crates/mysql) +[![Build Status](https://dev.azure.com/aikorsky/mysql%20Rust/_apis/build/status/blackbeam%2Erust%2Dmysql%2Dsimple)](https://dev.azure.com/aikorsky/mysql%20Rust/_build/latest?definitionId=1) + +# {{crate}} + +{{readme}} + +## Changelog + +Available [here](https://github.com/blackbeam/rust-mysql-simple/releases) + +## License + +Licensed under either of + +* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0) +* MIT license ([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally +submitted for inclusion in the work by you, as defined in the Apache-2.0 +license, shall be dual licensed as above, without any additional terms or +conditions. \ No newline at end of file diff --git a/vendor/mysql-28.0.0/azure-pipelines.yml b/vendor/mysql-28.0.0/azure-pipelines.yml new file mode 100644 index 0000000000..0e71aed175 --- /dev/null +++ b/vendor/mysql-28.0.0/azure-pipelines.yml @@ -0,0 +1,292 @@ +trigger: + - master + - ci-* + +jobs: + - job: "TestBasicLinux" + pool: + vmImage: "ubuntu-latest" + strategy: + maxParallel: 10 + matrix: + stable: + RUST_TOOLCHAIN: stable + beta: + RUST_TOOLCHAIN: beta + nightly: + RUST_TOOLCHAIN: nightly + steps: + - bash: | + sudo apt-get update + sudo apt-get -y install pkg-config libssl-dev build-essential + displayName: Install Dependencies + - bash: | + curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain $(RUST_TOOLCHAIN) + echo '##vso[task.setvariable variable=toolchain;isOutput=true]$(RUST_TOOLCHAIN)' + displayName: Install Rust + name: installRust + - bash: | + cargo fmt -- --check + condition: and(succeeded(), eq(variables['installRust.toolchain'], 'stable')) + displayName: cargo fmt + - bash: | + cargo clippy -- -Dclippy::dbg_macro -Dclippy::todo + condition: and(succeeded(), eq(variables['installRust.toolchain'], 'stable')) + displayName: lint + - bash: | + cargo check + cargo check --no-default-features --features default-rust + cargo check --no-default-features --features minimal + displayName: Run check + + - job: "TestBasicMacOs" + pool: + vmImage: "macOS-latest" + strategy: + maxParallel: 10 + matrix: + stable: + RUST_TOOLCHAIN: stable + steps: + - bash: | + curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain $RUST_TOOLCHAIN + displayName: Install rust (MacOs) + - bash: | + cargo check + cargo check --no-default-features --features default-rust + cargo check --no-default-features --features minimal + displayName: Run check + + - job: "TestBasicWindows" + pool: + vmImage: "windows-latest" + strategy: + maxParallel: 10 + matrix: + stable: + RUST_TOOLCHAIN: stable + steps: + - powershell: | + net stop MySQL + sc delete MySQL + winget uninstall Oracle.MySQL --accept-source-agreements + + $paths = @( + "C:\Program Files\MySQL", + "C:\Program Files (x86)\MySQL", + "C:\ProgramData\MySQL", + "$env:AppData\MySQL" + ) + + foreach ($path in $paths) { + if (Test-Path $path) { + Remove-Item -Path $path -Recurse -Force + Write-Host "Удалено: $path" + } + } + - script: | + choco install 7zip + mkdir C:\mysql + CD /D C:\mysql + curl -fsS --retry 3 --retry-connrefused -o mysql.msi https://cdn.mysql.com//Downloads/MySQLInstaller/mysql-installer-community-8.0.45.0.msi + msiexec /q /log install.txt /i mysql.msi datadir=C:\mysql installdir=C:\mysql + call "C:\Program Files (x86)\MySQL\MySQL Installer for Windows\MySQLInstallerConsole.exe" community install server;8.0.42;x64:*:port=3306;enable_named_pipe=true;rootpasswd=password;servicename=MySQL -silent + netsh advfirewall firewall add rule name="Allow mysql" dir=in action=allow edge=yes remoteip=any protocol=TCP localport=80,8080,3306 + net stop MySQL + "C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqld" --remove + echo [mysqld] >> C:\my.cnf + echo enable-named-pipe >> C:\my.cnf + echo socket=MYSQL >> C:\my.cnf + echo named_pipe_full_access_group=*everyone* >> C:\my.cnf + echo datadir=C:\\ProgramData\\MySQL\\MySQL Server 8.0\\Data\\ >> C:\my.cnf + "C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqld" --install MySQL --defaults-file=C:\my.cnf + net start MySQL + cat C:\my.cnf + cat "C:\ProgramData\MySQL\MySQL Server 8.0\Data\*err" + cat "C:\ProgramData\MySQL\MySQL Server 8.0\Data\*log" + "C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql" -e "SET GLOBAL max_allowed_packet = 36700160;" -uroot -ppassword + "C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql" -e "SET @@GLOBAL.ENFORCE_GTID_CONSISTENCY = WARN;" -uroot -ppassword + "C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql" -e "SET @@GLOBAL.ENFORCE_GTID_CONSISTENCY = ON;" -uroot -ppassword + "C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql" -e "SET @@GLOBAL.GTID_MODE = OFF_PERMISSIVE;" -uroot -ppassword + "C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql" -e "SET @@GLOBAL.GTID_MODE = ON_PERMISSIVE;" -uroot -ppassword + "C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql" -e "SET @@GLOBAL.GTID_MODE = ON;" -uroot -ppassword + "C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql" -e "SET GLOBAL local_infile=1;" -uroot -ppassword + "C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql" -e "SHOW VARIABLES;" -uroot -ppassword + displayName: Install MySql + - bash: | + rustup install $RUST_TOOLCHAIN + displayName: Install Rust (Windows) + - bash: | + SSL=false COMPRESS=false cargo test + SSL=true COMPRESS=false cargo test --features native-tls + SSL=false COMPRESS=true cargo test + SSL=true COMPRESS=true cargo test --features native-tls + + SSL=true COMPRESS=false cargo test --no-default-features --features rustls-tls,minimal-rust,time,frunk,binlog + SSL=true COMPRESS=true cargo test --no-default-features --features rustls-tls-ring,minimal-rust,time,frunk,binlog + + SSL=false COMPRESS=true cargo test --no-default-features --features minimal,time,frunk,binlog + SSL=false COMPRESS=false cargo test --no-default-features --features minimal,time,frunk,binlog + env: + RUST_BACKTRACE: 1 + DATABASE_URL: mysql://root:password@localhost/mysql + displayName: Run tests + + - job: "TestTiDB" + pool: + vmImage: "ubuntu-latest" + strategy: + matrix: + v7.5.1: + DB_VERSION: "v7.5.1" + v6.5.8: + DB_VERSION: "v6.5.8" + v5.3.4: + DB_VERSION: "v5.3.4" + v5.0.6: + DB_VERSION: "v5.0.6" + steps: + - bash: | + curl --proto '=https' --tlsv1.2 -sSf https://tiup-mirrors.pingcap.com/install.sh | sh + source ~/.profile + source ~/.bash_profile + tiup playground $(DB_VERSION) --db 1 --pd 1 --kv 1 & + while ! nc -W 1 localhost 4000 | grep -q -P '.+'; do sleep 1; done + displayName: Install and run TiDB + - bash: cargo test should_reuse_connections -- --nocapture + displayName: Run tests + env: + RUST_BACKTRACE: 1 + DATABASE_URL: mysql://root@127.0.0.1:4000/mysql + + - job: "TestMySql" + pool: + vmImage: "ubuntu-latest" + strategy: + maxParallel: 10 + matrix: + v91: + DB_VERSION: "9.1" + v90: + DB_VERSION: "9.0" + v84: + DB_VERSION: "8.4" + v80: + DB_VERSION: "8.0-debian" + v57: + DB_VERSION: "5.7-debian" + v56: + DB_VERSION: "5.6" + steps: + - bash: | + sudo apt-get update + sudo apt-get install docker.io netcat grep + sudo systemctl unmask docker + sudo systemctl start docker + docker --version + displayName: Install docker + - bash: | + if [[ "5.6" == "$(DB_VERSION)" ]]; then ARG="--secure-auth=OFF"; fi + docker run -d --name container -v `pwd`:/root -p 3307:3306 -e MYSQL_ROOT_PASSWORD=password mysql:$(DB_VERSION) --max-allowed-packet=36700160 --local-infile --log-bin=mysql-bin --log-slave-updates --gtid_mode=ON --enforce_gtid_consistency=ON --server-id=1 $ARG + while ! nc -W 1 localhost 3307 | grep -q -P '.+'; do sleep 1; done + displayName: Run MySql in Docker + - bash: | + docker exec container bash -l -c "mysql -uroot -ppassword -e \"SET old_passwords = 1; GRANT ALL PRIVILEGES ON *.* TO 'root2'@'%' IDENTIFIED WITH mysql_old_password AS 'password'; SET PASSWORD FOR 'root2'@'%' = OLD_PASSWORD('password')\""; + docker exec container bash -l -c "echo 'deb [trusted=yes] http://archive.debian.org/debian/ stretch main non-free contrib' > /etc/apt/sources.list" + docker exec container bash -l -c "echo 'deb-src [trusted=yes] http://archive.debian.org/debian/ stretch main non-free contrib ' >> /etc/apt/sources.list" + docker exec container bash -l -c "echo 'deb [trusted=yes] http://archive.debian.org/debian-security/ stretch/updates main non-free contrib' >> /etc/apt/sources.list" + docker exec container bash -l -c "echo 'deb [trusted=yes] http://repo.mysql.com/apt/debian/ stretch mysql-5.6' > /etc/apt/sources.list.d/mysql.list" + condition: eq(variables['DB_VERSION'], '5.6') + - bash: | + docker exec container bash -l -c "apt-get --allow-unauthenticated -y update" + docker exec container bash -l -c "apt-get install -y curl clang libssl-dev pkg-config build-essential" + docker exec container bash -l -c "curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable" + displayName: Install Rust in docker (Debian) + condition: or(eq(variables['DB_VERSION'], '5.6'), eq(variables['DB_VERSION'], '5.7-debian'), eq(variables['DB_VERSION'], '8.0-debian')) + - bash: | + docker exec container bash -l -c "microdnf install dnf" + docker exec container bash -l -c "dnf group install \"Development Tools\"" + docker exec container bash -l -c "curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable" + displayName: Install Rust in docker (RedHat) + condition: not(or(eq(variables['DB_VERSION'], '5.6'), eq(variables['DB_VERSION'], '5.7-debian'), eq(variables['DB_VERSION'], '8.0-debian'))) + - bash: | + if [[ "5.6" != "$(DB_VERSION)" ]]; then SSL=true; else DATABASE_URL="mysql://root2:password@localhost/mysql?secure_auth=false"; fi + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL cargo test" + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL COMPRESS=true cargo test" + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=$SSL cargo test --features native-tls" + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=$SSL COMPRESS=true cargo test --features native-tls" + + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=true COMPRESS=false cargo test --no-default-features --features rustls-tls,minimal-rust,time,frunk,binlog" + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=true COMPRESS=true cargo test --no-default-features --features rustls-tls-ring,minimal-rust,time,frunk,binlog" + + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=false COMPRESS=true cargo test --no-default-features --features minimal,time,frunk,binlog" + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=false COMPRESS=false cargo test --no-default-features --features minimal,time,frunk,binlog" + env: + RUST_BACKTRACE: 1 + DATABASE_URL: mysql://root:password@localhost/mysql + displayName: Run tests in Docker + + - job: "TestMariaDb" + pool: + vmImage: "ubuntu-latest" + strategy: + maxParallel: 10 + matrix: + verylatest: + CONTAINER: "quay.io/mariadb-foundation/mariadb-devel:verylatest" + latest: + CONTAINER: "quay.io/mariadb-foundation/mariadb-devel:latest" + lts: + CONTAINER: "mariadb:lts" + steps: + - bash: | + sudo apt-get update + sudo apt-get install docker.io + sudo systemctl unmask docker + sudo systemctl start docker + docker --version + displayName: Install docker + - bash: | + docker run --rm -d \ + --name container \ + -v `pwd`:/root \ + -p 3307:3306 \ + -e MARIADB_ROOT_PASSWORD=password \ + $(CONTAINER) \ + --max-allowed-packet=36700160 \ + --local-infile \ + --performance-schema=on \ + --log-bin=mysql-bin --gtid-domain-id=1 \ + --server-id=1 \ + --ssl \ + --ssl-ca=/root/tests/ca.crt \ + --ssl-cert=/root/tests/server.crt \ + --ssl-key=/root/tests/server-key.pem \ + --secure-auth=OFF \ + --plugin-load-add=auth_parsec \ + --plugin-load-add=auth_ed25519 & + while ! docker exec container healthcheck.sh --connect --innodb_initialized ; do sleep 1; echo waiting; done + docker logs container + docker exec container bash -l -c "mariadb -uroot -ppassword -e 'SHOW VARIABLES LIKE \"%ssl%\"'" + docker exec container bash -l -c "ls -la /root/tests" + displayName: Run MariaDb in Docker + - bash: | + docker exec container bash -l -c "apt-get update" + docker exec container bash -l -c "apt-get install -y curl clang libssl-dev pkg-config" + docker exec container bash -l -c "curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable" + displayName: Install Rust in docker + - bash: | + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL cargo test --features client_ed25519,client_parsec" + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL COMPRESS=true cargo test --features client_ed25519,client_parsec" + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=true cargo test --features native-tls,client_ed25519,client_parsec" + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=true COMPRESS=true cargo test --features native-tls,client_ed25519,client_parsec" + + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=true COMPRESS=false cargo test --no-default-features --features rustls-tls,minimal-rust,time,frunk,binlog,client_ed25519,client_parsec" + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=true COMPRESS=true cargo test --no-default-features --features rustls-tls-ring,minimal-rust,time,frunk,binlog,client_ed25519,client_parsec" + + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=false COMPRESS=true cargo test --no-default-features --features minimal,time,frunk,binlog,client_ed25519,client_parsec" + docker exec container bash -l -c "cd \$HOME && DATABASE_URL=$DATABASE_URL SSL=false COMPRESS=false cargo test --no-default-features --features minimal,time,frunk,binlog,client_ed25519,client_parsec" + env: + RUST_BACKTRACE: 1 + DATABASE_URL: mysql://root:password@localhost/mysql + displayName: Run tests in Docker diff --git a/vendor/mysql-28.0.0/build.rs b/vendor/mysql-28.0.0/build.rs new file mode 100644 index 0000000000..2cf3029400 --- /dev/null +++ b/vendor/mysql-28.0.0/build.rs @@ -0,0 +1,18 @@ +// Copyright (c) 2020 rust-mysql-common contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use std::env; + +fn main() { + let names = ["CARGO_CFG_TARGET_OS", "CARGO_CFG_TARGET_ARCH"]; + for name in &names { + let value = env::var(name) + .unwrap_or_else(|_| panic!("Could not get the environment variable {}", name)); + println!("cargo:rustc-env={}={}", name, value); + } +} diff --git a/vendor/mysql-28.0.0/src/buffer_pool/disabled.rs b/vendor/mysql-28.0.0/src/buffer_pool/disabled.rs new file mode 100644 index 0000000000..16f61fe388 --- /dev/null +++ b/vendor/mysql-28.0.0/src/buffer_pool/disabled.rs @@ -0,0 +1,25 @@ +#![cfg(not(feature = "buffer-pool"))] + +use std::ops::Deref; + +#[derive(Debug)] +#[repr(transparent)] +pub struct Buffer(Vec); + +impl AsMut> for Buffer { + fn as_mut(&mut self) -> &mut Vec { + &mut self.0 + } +} + +impl Deref for Buffer { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.0.deref() + } +} + +pub const fn get_buffer() -> Buffer { + Buffer(Vec::new()) +} diff --git a/vendor/mysql-28.0.0/src/buffer_pool/enabled.rs b/vendor/mysql-28.0.0/src/buffer_pool/enabled.rs new file mode 100644 index 0000000000..21432c756d --- /dev/null +++ b/vendor/mysql-28.0.0/src/buffer_pool/enabled.rs @@ -0,0 +1,105 @@ +#![cfg(feature = "buffer-pool")] + +use crossbeam_queue::ArrayQueue; + +use std::{ + mem::take, + ops::Deref, + sync::{Arc, OnceLock}, +}; + +const DEFAULT_MYSQL_BUFFER_POOL_CAP: usize = 128; +const DEFAULT_MYSQL_BUFFER_SIZE_CAP: usize = 4 * 1024 * 1024; + +#[inline(always)] +pub fn get_buffer() -> Buffer { + static BUFFER_POOL: OnceLock> = OnceLock::new(); + BUFFER_POOL.get_or_init(Default::default).get() +} + +#[derive(Debug)] +struct Inner { + buffer_cap: usize, + pool: ArrayQueue>, +} + +impl Inner { + fn get(self: &Arc) -> Buffer { + let mut buf = self.pool.pop().unwrap_or_default(); + + // SAFETY: + // 1. OK – 0 is always within capacity + // 2. OK - nothing to initialize + unsafe { buf.set_len(0) } + + Buffer(buf, Some(self.clone())) + } + + fn put(&self, mut buf: Vec) { + buf.shrink_to(self.buffer_cap); + let _ = self.pool.push(buf); + } +} + +/// Smart pointer to a buffer pool. +#[derive(Debug, Clone)] +pub struct BufferPool(Option>); + +impl BufferPool { + pub fn new() -> Self { + let pool_cap = std::env::var("RUST_MYSQL_BUFFER_POOL_CAP") + .ok() + .and_then(|x| x.parse().ok()) + .unwrap_or(DEFAULT_MYSQL_BUFFER_POOL_CAP); + + let buffer_cap = std::env::var("RUST_MYSQL_BUFFER_SIZE_CAP") + .ok() + .and_then(|x| x.parse().ok()) + .unwrap_or(DEFAULT_MYSQL_BUFFER_SIZE_CAP); + + Self((pool_cap > 0).then(|| { + Arc::new(Inner { + buffer_cap, + pool: ArrayQueue::new(pool_cap), + }) + })) + } + + pub fn get(self: &Arc) -> Buffer { + match self.0 { + Some(ref inner) => inner.get(), + None => Buffer(Vec::new(), None), + } + } +} + +impl Default for BufferPool { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug)] +pub struct Buffer(Vec, Option>); + +impl AsMut> for Buffer { + fn as_mut(&mut self) -> &mut Vec { + &mut self.0 + } +} + +impl Deref for Buffer { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.0.deref() + } +} + +impl Drop for Buffer { + fn drop(&mut self) { + if let Some(ref inner) = self.1 { + inner.put(take(&mut self.0)); + } + } +} diff --git a/vendor/mysql-28.0.0/src/buffer_pool/mod.rs b/vendor/mysql-28.0.0/src/buffer_pool/mod.rs new file mode 100644 index 0000000000..95565e2aa1 --- /dev/null +++ b/vendor/mysql-28.0.0/src/buffer_pool/mod.rs @@ -0,0 +1,16 @@ +// Copyright (c) 2021 Anatoly Ikorsky +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +mod disabled; +mod enabled; + +#[cfg(feature = "buffer-pool")] +pub use enabled::{get_buffer, Buffer}; + +#[cfg(not(feature = "buffer-pool"))] +pub use disabled::{get_buffer, Buffer}; diff --git a/vendor/mysql-28.0.0/src/conn/binlog_stream.rs b/vendor/mysql-28.0.0/src/conn/binlog_stream.rs new file mode 100644 index 0000000000..eeec580fc4 --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/binlog_stream.rs @@ -0,0 +1,89 @@ +// Copyright (c) 2021 Anatoly Ikorsky +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use mysql_common::{ + binlog::{ + consts::BinlogVersion::Version4, + events::{Event, TableMapEvent}, + EventStreamReader, + }, + io::ParseBuf, + packets::{ErrPacket, NetworkStreamTerminator, OkPacketDeserializer}, +}; + +use crate::Conn; + +/// Binlog event stream. +/// +/// Stream initialization is lazy, i.e. binlog won't be requested until this stream is polled. +#[cfg_attr(docsrs, doc(cfg(feature = "binlog")))] +pub struct BinlogStream { + conn: Option, + esr: EventStreamReader, +} + +impl BinlogStream { + /// `conn` is a `Conn` with `request_binlog` executed on it. + pub(super) fn new(conn: Conn) -> Self { + BinlogStream { + conn: Some(conn), + esr: EventStreamReader::new(Version4), + } + } + + /// Returns a table map event for the given table id. + pub fn get_tme(&self, table_id: u64) -> Option<&TableMapEvent<'static>> { + self.esr.get_tme(table_id) + } +} + +impl Iterator for BinlogStream { + type Item = crate::Result; + + fn next(&mut self) -> Option { + let conn = self.conn.as_mut()?; + + let packet = match conn.read_packet() { + Ok(packet) => packet, + Err(err) => { + self.conn = None; + return Some(Err(err)); + } + }; + + let first_byte = packet.first().copied(); + + if first_byte == Some(255) { + if let Ok(ErrPacket::Error(err)) = ParseBuf(&packet).parse(conn.0.capability_flags) { + self.conn = None; + return Some(Err(crate::Error::MySqlError(From::from(err)))); + } + } + + if first_byte == Some(254) + && packet.len() < 8 + && ParseBuf(&packet) + .parse::>(conn.0.capability_flags) + .is_ok() + { + self.conn = None; + return None; + } + + if first_byte == Some(0) { + let event_data = &packet[1..]; + match self.esr.read(event_data) { + Ok(event) => Some(Ok(event?)), + Err(err) => Some(Err(err.into())), + } + } else { + self.conn = None; + Some(Err(crate::error::DriverError::UnexpectedPacket.into())) + } + } +} diff --git a/vendor/mysql-28.0.0/src/conn/local_infile.rs b/vendor/mysql-28.0.0/src/conn/local_infile.rs new file mode 100644 index 0000000000..e8a56f4faf --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/local_infile.rs @@ -0,0 +1,133 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use std::{ + fmt, io, + sync::{Arc, Mutex}, +}; + +use crate::Conn; + +pub(crate) type LocalInfileInner = + Arc FnMut(&'a [u8], &'a mut LocalInfile<'_>) -> io::Result<()> + Send>>; + +/// Callback to handle requests for local files. +/// Consult [Mysql documentation](https://dev.mysql.com/doc/refman/5.7/en/load-data.html) for the +/// format of local infile data. +/// +/// # Support +/// +/// Note that older versions of Mysql server may not support this functionality. +/// +/// ```rust +/// # mysql::doctest_wrapper!(__result, { +/// use mysql::*; +/// use mysql::prelude::*; +/// +/// use std::io::Write; +/// +/// let pool = Pool::new(get_opts())?; +/// let mut conn = pool.get_conn().unwrap(); +/// +/// conn.query_drop("CREATE TEMPORARY TABLE mysql.tbl(a TEXT)").unwrap(); +/// conn.set_local_infile_handler(Some( +/// LocalInfileHandler::new(|file_name, writer| { +/// writer.write_all(b"row1: file name is ")?; +/// writer.write_all(file_name)?; +/// writer.write_all(b"\n")?; +/// +/// writer.write_all(b"row2: foobar\n") +/// }) +/// )); +/// +/// match conn.query_drop("LOAD DATA LOCAL INFILE 'file_name' INTO TABLE mysql.tbl") { +/// Ok(_) => (), +/// Err(Error::MySqlError(ref e)) if e.code == 1148 => { +/// // functionality is not supported by the server +/// return Ok(()); +/// } +/// err => { +/// err.unwrap(); +/// } +/// } +/// +/// let mut row_num = 0; +/// let result: Vec = conn.query("SELECT * FROM mysql.tbl").unwrap(); +/// assert_eq!( +/// result, +/// vec!["row1: file name is file_name".to_string(), "row2: foobar".to_string()], +/// ); +/// # }); +/// ``` +#[derive(Clone)] +pub struct LocalInfileHandler(pub(crate) LocalInfileInner); + +impl LocalInfileHandler { + pub fn new(f: F) -> Self + where + F: for<'a> FnMut(&'a [u8], &'a mut LocalInfile<'_>) -> io::Result<()> + Send + 'static, + { + LocalInfileHandler(Arc::new(Mutex::new(f))) + } +} + +impl PartialEq for LocalInfileHandler { + fn eq(&self, other: &LocalInfileHandler) -> bool { + std::ptr::eq(&*self.0, &*other.0) + } +} + +impl Eq for LocalInfileHandler {} + +impl fmt::Debug for LocalInfileHandler { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + write!(f, "LocalInfileHandler(...)") + } +} + +/// Local in-file stream. +/// The callback will be passed a reference to this stream, which it +/// should use to write the contents of the requested file. +/// See [LocalInfileHandler](struct.LocalInfileHandler.html) documentation for example. +#[derive(Debug)] +pub struct LocalInfile<'a> { + buffer: io::Cursor<&'a mut [u8]>, + conn: &'a mut Conn, +} + +impl<'a> LocalInfile<'a> { + pub(crate) const BUFFER_SIZE: usize = 4096; + + pub(crate) fn new(buffer: &'a mut [u8; LocalInfile::BUFFER_SIZE], conn: &'a mut Conn) -> Self { + Self { + buffer: io::Cursor::new(buffer), + conn, + } + } +} + +impl io::Write for LocalInfile<'_> { + fn write(&mut self, buf: &[u8]) -> io::Result { + if self.buffer.position() == Self::BUFFER_SIZE as u64 { + self.flush()?; + } + self.buffer.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + let n = self.buffer.position() as usize; + if n > 0 { + let mut range = &self.buffer.get_ref()[..n]; + self.conn + .write_packet(&mut range) + .map_err(io::Error::other)?; + } + self.buffer.set_position(0); + Ok(()) + } +} diff --git a/vendor/mysql-28.0.0/src/conn/mod.rs b/vendor/mysql-28.0.0/src/conn/mod.rs new file mode 100644 index 0000000000..fe063a4dfe --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/mod.rs @@ -0,0 +1,3316 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use bytes::{Buf, BufMut}; +#[cfg(feature = "binlog")] +use mysql_common::packets::binlog_request::BinlogRequest; +use mysql_common::{ + constants::UTF8MB4_GENERAL_CI, + crypto, + io::{ParseBuf, ReadMysqlExt}, + named_params::ParsedNamedParams, + packets::{ + AuthPlugin, AuthSwitchRequest, Column, ComChangeUser, ComChangeUserMoreData, + ComStmtBulkExecuteRequestBuilder, ComStmtClose, ComStmtExecuteRequestBuilder, + ComStmtSendLongData, CommonOkPacket, ErrPacket, HandshakePacket, HandshakeResponse, + OkPacket, OkPacketDeserializer, OkPacketKind, OldAuthSwitchRequest, OldEofPacket, + ResultSetTerminator, SessionStateInfo, + }, + proto::{codec::Compression, sync_framed::MySyncFramed, MySerialize}, +}; + +use mysql_common::{ + constants::{DEFAULT_MAX_ALLOWED_PACKET, UTF8_GENERAL_CI}, + packets::SslRequest, +}; + +use std::{ + borrow::{Borrow, Cow}, + collections::HashMap, + convert::TryFrom, + io::{self, Write as _}, + mem, + ops::{Deref, DerefMut}, + process, + sync::Arc, +}; + +#[cfg(unix)] +use std::os::unix::io::{AsRawFd, RawFd}; + +use crate::{ + buffer_pool::{get_buffer, Buffer}, + conn::{ + local_infile::LocalInfile, + pool::{Pool, PooledConn}, + query_result::{Binary, ResultSetMeta, Text}, + stmt::{InnerStmt, Statement}, + stmt_cache::StmtCache, + transaction::{AccessMode, TxOpts}, + }, + consts::{CapabilityFlags, Command, MariadbCapabilities, StatusFlags, MAX_PAYLOAD_LEN}, + from_value, from_value_opt, + io::Stream, + prelude::*, + ChangeUserOpts, + DriverError::{ + CleartextPluginDisabled, MismatchedStmtParams, OldMysqlPasswordDisabled, Protocol41NotSet, + ReadOnlyTransNotSupported, SetupError, UnexpectedPacket, UnknownAuthPlugin, + UnsupportedProtocol, + }, + Error::{self, DriverError, MySqlError}, + LocalInfileHandler, Opts, OptsBuilder, Params, QueryResult, Result, Transaction, + Value::{self, Bytes, NULL}, +}; + +use crate::DriverError::TlsNotSupported; +use crate::SslOpts; + +#[cfg(feature = "binlog")] +use self::binlog_stream::BinlogStream; + +#[cfg(feature = "binlog")] +pub mod binlog_stream; +pub mod local_infile; +pub mod opts; +pub mod pool; +pub mod query; +pub mod query_result; +pub mod queryable; +pub mod stmt; +mod stmt_cache; +pub mod transaction; + +/// Mutable connection. +#[derive(Debug)] +pub enum ConnMut<'c, 't, 'tc> { + Mut(&'c mut Conn), + TxMut(&'t mut Transaction<'tc>), + Owned(Conn), + Pooled(PooledConn), +} + +impl From for ConnMut<'static, 'static, 'static> { + fn from(conn: Conn) -> Self { + ConnMut::Owned(conn) + } +} + +impl From for ConnMut<'static, 'static, 'static> { + fn from(conn: PooledConn) -> Self { + ConnMut::Pooled(conn) + } +} + +impl<'a> From<&'a mut Conn> for ConnMut<'a, 'static, 'static> { + fn from(conn: &'a mut Conn) -> Self { + ConnMut::Mut(conn) + } +} + +impl<'a> From<&'a mut PooledConn> for ConnMut<'a, 'static, 'static> { + fn from(conn: &'a mut PooledConn) -> Self { + ConnMut::Mut(conn.as_mut()) + } +} + +impl<'t, 'tc> From<&'t mut Transaction<'tc>> for ConnMut<'static, 't, 'tc> { + fn from(tx: &'t mut Transaction<'tc>) -> Self { + ConnMut::TxMut(tx) + } +} + +impl TryFrom<&Pool> for ConnMut<'static, 'static, 'static> { + type Error = Error; + + fn try_from(pool: &Pool) -> Result { + pool.get_conn().map(From::from) + } +} + +impl Deref for ConnMut<'_, '_, '_> { + type Target = Conn; + + fn deref(&self) -> &Conn { + match self { + ConnMut::Mut(conn) => conn, + ConnMut::TxMut(tx) => &tx.conn, + ConnMut::Owned(conn) => conn, + ConnMut::Pooled(conn) => conn.as_ref(), + } + } +} + +impl DerefMut for ConnMut<'_, '_, '_> { + fn deref_mut(&mut self) -> &mut Conn { + match self { + ConnMut::Mut(conn) => conn, + ConnMut::TxMut(tx) => &mut tx.conn, + ConnMut::Owned(ref mut conn) => conn, + ConnMut::Pooled(ref mut conn) => conn.as_mut(), + } + } +} + +#[derive(Debug)] +pub(crate) enum ResultSetInfo { + Empty(OkPacket<'static>), + NonEmptyWithMeta(Vec), + // For MariaDB via MARIADB_CLIENT_CACHE_METADATA + NonEmptySkipMeta, +} + +impl ResultSetInfo { + pub(crate) fn into_query_meta(self) -> ResultSetMeta { + match self { + ResultSetInfo::Empty(ok_packet) => ResultSetMeta::Empty(ok_packet), + ResultSetInfo::NonEmptyWithMeta(columns) => { + ResultSetMeta::NonEmptyWithMeta(columns.into()) + } + ResultSetInfo::NonEmptySkipMeta => { + // TODO: Server misbehavior — emit runtime error + ResultSetMeta::NonEmptyWithMeta(Default::default()) + } + } + } + + pub(crate) fn into_statement_meta(self, conn: &Conn, stmt: &Statement) -> ResultSetMeta { + match self { + ResultSetInfo::Empty(ok_packet) => ResultSetMeta::Empty(ok_packet), + ResultSetInfo::NonEmptyWithMeta(columns) => { + stmt.update_columns_metadata(columns); + ResultSetMeta::NonEmptyWithMeta(stmt.columns()) + } + ResultSetInfo::NonEmptySkipMeta => { + assert!( + conn.has_mariadb_capability(MariadbCapabilities::MARIADB_CLIENT_CACHE_METADATA), + "metadata skipped but no MARIADB_CLIENT_CACHE_METADATA capability negotiated" + ); + ResultSetMeta::NonEmptyWithMeta(stmt.columns()) + } + } + } +} + +/// Connection internals. +#[derive(Debug)] +struct ConnInner { + opts: Opts, + stream: Option>, + stmt_cache: StmtCache, + + // TODO: clean this up + server_version: Option<(u16, u16, u16)>, + mariadb_server_version: Option<(u16, u16, u16)>, + + /// Last Ok packet, if any. + ok_packet: Option>, + capability_flags: CapabilityFlags, + mariadb_ext_capabilities: MariadbCapabilities, + connection_id: u32, + status_flags: StatusFlags, + character_set: u8, + last_command: u8, + connected: bool, + has_results: bool, + local_infile_handler: Option, + + auth_plugin: AuthPlugin<'static>, + nonce: Vec, + + /// This flag is to opt-in/opt-out from reset upon return to a pool. + pub(crate) reset_upon_return: bool, +} + +impl ConnInner { + fn empty(opts: Opts) -> Self { + ConnInner { + stmt_cache: StmtCache::new(opts.get_stmt_cache_size()), + stream: None, + capability_flags: CapabilityFlags::empty(), + status_flags: StatusFlags::empty(), + connection_id: 0u32, + character_set: 0u8, + ok_packet: None, + last_command: 0u8, + connected: false, + has_results: false, + server_version: None, + mariadb_server_version: None, + mariadb_ext_capabilities: MariadbCapabilities::empty(), + local_infile_handler: None, + auth_plugin: AuthPlugin::MysqlNativePassword, + nonce: Vec::new(), + reset_upon_return: opts.get_pool_opts().reset_connection(), + + opts, + } + } +} + +/// Mysql connection. +#[derive(Debug)] +pub struct Conn(Box); + +impl Conn { + /// Must not be called before handle_handshake. + const fn has_capability(&self, flag: CapabilityFlags) -> bool { + self.0.capability_flags.contains(flag) + } + + /// Must not be called before handle_handshake. + const fn has_mariadb_capability(&self, flag: MariadbCapabilities) -> bool { + self.0.mariadb_ext_capabilities.contains(flag) + } + + /// Returns version number reported by the server. + pub fn server_version(&self) -> (u16, u16, u16) { + self.0 + .server_version + .or(self.0.mariadb_server_version) + .unwrap() + } + + /// Returns connection identifier. + pub fn connection_id(&self) -> u32 { + self.0.connection_id + } + + /// Returns number of rows affected by the last query. + pub fn affected_rows(&self) -> u64 { + self.0 + .ok_packet + .as_ref() + .map(OkPacket::affected_rows) + .unwrap_or_default() + } + + /// Returns last insert id of the last query. + /// + /// Returns zero if there was no last insert id. + pub fn last_insert_id(&self) -> u64 { + self.0 + .ok_packet + .as_ref() + .and_then(OkPacket::last_insert_id) + .unwrap_or_default() + } + + /// Returns number of warnings, reported by the server. + pub fn warnings(&self) -> u16 { + self.0 + .ok_packet + .as_ref() + .map(OkPacket::warnings) + .unwrap_or_default() + } + + /// [Info], reported by the server. + /// + /// Will be empty if not defined. + /// + /// [Info]: http://dev.mysql.com/doc/internals/en/packet-OK_Packet.html + pub fn info_ref(&self) -> &[u8] { + self.0 + .ok_packet + .as_ref() + .and_then(OkPacket::info_ref) + .unwrap_or_default() + } + + /// [Info], reported by the server. + /// + /// Will be empty if not defined. + /// + /// [Info]: http://dev.mysql.com/doc/internals/en/packet-OK_Packet.html + pub fn info_str(&self) -> Cow<'_, str> { + self.0 + .ok_packet + .as_ref() + .and_then(OkPacket::info_str) + .unwrap_or_default() + } + + pub fn session_state_changes(&self) -> io::Result>> { + self.0 + .ok_packet + .as_ref() + .map(|ok| ok.session_state_info()) + .transpose() + .map(Option::unwrap_or_default) + } + + fn stream_ref(&self) -> &MySyncFramed { + self.0.stream.as_ref().expect("incomplete connection") + } + + fn stream_mut(&mut self) -> &mut MySyncFramed { + self.0.stream.as_mut().expect("incomplete connection") + } + + fn is_insecure(&self) -> bool { + self.stream_ref().get_ref().is_insecure() + } + + fn is_socket(&self) -> bool { + self.stream_ref().get_ref().is_socket() + } + + /// Check the connection can be improved. + #[allow(unused_assignments)] + fn can_improved(&mut self) -> Result> { + if self.0.opts.get_prefer_socket() && self.0.opts.addr_is_loopback() { + let mut socket = None; + #[cfg(test)] + { + socket = self.0.opts.0.injected_socket.clone(); + } + if socket.is_none() { + socket = self.get_system_var("socket")?.map(from_value::); + } + if let Some(socket) = socket { + if self.0.opts.get_socket().is_none() { + let socket_opts = OptsBuilder::from_opts(self.0.opts.clone()); + if !socket.is_empty() { + return Ok(Some(socket_opts.socket(Some(socket)).into())); + } + } + } + } + Ok(None) + } + + /// Creates new `Conn`. + pub fn new(opts: T) -> Result + where + Opts: TryFrom, + crate::Error: From, + { + let opts = Opts::try_from(opts)?; + let mut conn = Conn(Box::new(ConnInner::empty(opts))); + conn.connect_stream()?; + conn.connect()?; + let mut conn = { + if let Some(new_opts) = conn.can_improved()? { + let mut improved_conn = Conn(Box::new(ConnInner::empty(new_opts))); + improved_conn + .connect_stream() + .and_then(|_| { + improved_conn.connect()?; + Ok(improved_conn) + }) + .unwrap_or(conn) + } else { + conn + } + }; + for cmd in conn.0.opts.get_init() { + conn.query_drop(cmd)?; + } + Ok(conn) + } + + fn exec_com_reset_connection(&mut self) -> Result<()> { + self.write_command(Command::COM_RESET_CONNECTION, &[])?; + let packet = self.read_packet()?; + self.handle_ok::(&packet)?; + self.0.last_command = 0; + self.0.stmt_cache.clear(); + Ok(()) + } + + fn exec_com_change_user(&mut self, opts: ChangeUserOpts) -> Result<()> { + opts.update_opts(&mut self.0.opts); + let com_change_user = ComChangeUser::new() + .with_user(self.0.opts.get_user().map(|x| x.as_bytes())) + .with_database(self.0.opts.get_db_name().map(|x| x.as_bytes())) + .with_auth_plugin_data( + self.0 + .auth_plugin + .gen_data(self.0.opts.get_pass(), &self.0.nonce) + .as_deref(), + ) + .with_more_data(Some( + ComChangeUserMoreData::new(if self.server_version() >= (5, 5, 3) { + UTF8MB4_GENERAL_CI + } else { + UTF8_GENERAL_CI + }) + .with_auth_plugin(Some(self.0.auth_plugin.clone())) + .with_connect_attributes(self.0.opts.get_connect_attrs().cloned()), + )) + .into_owned(); + self.write_command_raw(&com_change_user)?; + self.0.last_command = 0; + self.0.stmt_cache.clear(); + self.continue_auth(false) + } + + /// Tries to reset the connection. + /// + /// This function will try to invoke COM_RESET_CONNECTION with + /// a fall back to COM_CHANGE_USER on older servers. + /// + /// ## Warning + /// + /// There is a long-standing bug in mysql 5.6 that kills this functionality in presence + /// of connection attributes (see [Bug #92954](https://bugs.mysql.com/bug.php?id=92954)). + /// + /// ## Note + /// + /// Re-executes [`Opts::get_init`]. + pub fn reset(&mut self) -> Result<()> { + let reset_result = match (self.0.server_version, self.0.mariadb_server_version) { + (Some(ref version), _) if *version > (5, 7, 3) => self.exec_com_reset_connection(), + (_, Some(ref version)) if *version >= (10, 2, 7) => self.exec_com_reset_connection(), + _ => return self.exec_com_change_user(ChangeUserOpts::DEFAULT), + }; + + match reset_result { + Ok(_) => (), + Err(crate::Error::MySqlError(_)) => { + // fallback to COM_CHANGE_USER if server reports an error for COM_RESET_CONNECTION + self.exec_com_change_user(ChangeUserOpts::DEFAULT)?; + } + Err(e) => return Err(e), + } + + for cmd in self.0.opts.get_init() { + self.query_drop(cmd)?; + } + + Ok(()) + } + + /// Executes [`COM_CHANGE_USER`][1]. + /// + /// This might be used as an older and slower alternative to `COM_RESET_CONNECTION` that + /// works on MySql prior to 5.7.3 (MariaDb prior ot 10.2.4). + /// + /// ## Note + /// + /// * Using non-default `opts` for a pooled connection is discouraging. + /// * Connection options will be updated permanently. + /// + /// ## Warning + /// + /// There is a long-standing bug in mysql 5.6 that kills this functionality in presence + /// of connection attributes (see [Bug #92954](https://bugs.mysql.com/bug.php?id=92954)). + /// + /// [1]: https://dev.mysql.com/doc/c-api/5.7/en/mysql-change-user.html + pub fn change_user(&mut self, opts: ChangeUserOpts) -> Result<()> { + self.exec_com_change_user(opts) + } + + fn switch_to_ssl(&mut self, ssl_opts: SslOpts) -> Result<()> { + let stream = self.0.stream.take().expect("incomplete conn"); + let (in_buf, out_buf, codec, stream) = stream.destruct(); + let stream = stream.make_secure(self.0.opts.get_host(), ssl_opts)?; + let stream = MySyncFramed::construct(in_buf, out_buf, codec, stream); + self.0.stream = Some(stream); + Ok(()) + } + + fn connect_stream(&mut self) -> Result<()> { + let opts = &self.0.opts; + let read_timeout = opts.get_read_timeout().cloned(); + let write_timeout = opts.get_write_timeout().cloned(); + let tcp_keepalive_time = opts.get_tcp_keepalive_time_ms(); + #[cfg(any(target_os = "linux", target_os = "macos",))] + let tcp_keepalive_probe_interval_secs = opts.get_tcp_keepalive_probe_interval_secs(); + #[cfg(any(target_os = "linux", target_os = "macos",))] + let tcp_keepalive_probe_count = opts.get_tcp_keepalive_probe_count(); + #[cfg(target_os = "linux")] + let tcp_user_timeout = opts.get_tcp_user_timeout_ms(); + let tcp_nodelay = opts.get_tcp_nodelay(); + let tcp_connect_timeout = opts.get_tcp_connect_timeout(); + let bind_address = opts.bind_address().cloned(); + let stream = if let Some(socket) = opts.get_socket() { + Stream::connect_socket(socket, read_timeout, write_timeout)? + } else { + let port = opts.get_tcp_port(); + let ip_or_hostname = match opts.get_host() { + url::Host::Domain(domain) => domain, + url::Host::Ipv4(ip) => ip.to_string(), + url::Host::Ipv6(ip) => ip.to_string(), + }; + Stream::connect_tcp( + &ip_or_hostname, + port, + read_timeout, + write_timeout, + tcp_keepalive_time, + #[cfg(any(target_os = "linux", target_os = "macos",))] + tcp_keepalive_probe_interval_secs, + #[cfg(any(target_os = "linux", target_os = "macos",))] + tcp_keepalive_probe_count, + #[cfg(target_os = "linux")] + tcp_user_timeout, + tcp_nodelay, + tcp_connect_timeout, + bind_address, + )? + }; + self.0.stream = Some(MySyncFramed::new(stream)); + Ok(()) + } + + fn raw_read_packet(&mut self, buffer: &mut Vec) -> Result<()> { + if !self.stream_mut().next_packet(buffer)? { + Err(Error::server_disconnected()) + } else { + Ok(()) + } + } + + fn read_packet(&mut self) -> Result { + loop { + let mut buffer = get_buffer(); + match self.raw_read_packet(buffer.as_mut()) { + Ok(()) if buffer.first() == Some(&0xff) => { + match ParseBuf(&buffer).parse(self.0.capability_flags)? { + ErrPacket::Error(server_error) => { + self.handle_err(); + return Err(MySqlError(From::from(server_error))); + } + ErrPacket::Progress(_progress_report) => { + // TODO: Report progress + continue; + } + } + } + Ok(()) => return Ok(buffer), + Err(e) => { + self.handle_err(); + return Err(e); + } + } + } + } + + fn drop_packet(&mut self) -> Result<()> { + self.read_packet().map(drop) + } + + fn write_struct(&mut self, s: &T) -> Result<()> { + let mut buf = get_buffer(); + s.serialize(buf.as_mut()); + self.write_packet(&mut &*buf) + } + + fn write_packet(&mut self, data: &mut T) -> Result<()> { + self.stream_mut().send(data)?; + Ok(()) + } + + fn handle_handshake(&mut self, hp: &HandshakePacket<'_>) { + self.0.capability_flags = hp.capabilities() & self.get_client_flags(); + self.0.status_flags = hp.status_flags(); + self.0.connection_id = hp.connection_id(); + self.0.character_set = hp.default_collation(); + self.0.server_version = hp.server_version_parsed(); + self.0.mariadb_server_version = hp.maria_db_server_version_parsed(); + // If we have a MariaDB server version, we are using mariadb extended capabilities from the handshake packet. + // MariaDB does not set 1 standard capability flag bit to indicate that it supports extended capabilities. + if self.0.mariadb_server_version.is_some() + && !self + .0 + .capability_flags + .contains(CapabilityFlags::CLIENT_LONG_PASSWORD) + { + self.0.mariadb_ext_capabilities = + hp.mariadb_ext_capabilities() & self.get_mariadb_client_flags(); + } + } + + fn handle_ok<'a, T: OkPacketKind>( + &mut self, + buffer: &'a Buffer, + ) -> crate::Result> { + let ok = ParseBuf(buffer) + .parse::>(self.0.capability_flags)? + .into_inner(); + self.0.status_flags = ok.status_flags(); + self.0.ok_packet = Some(ok.clone().into_owned()); + Ok(ok) + } + + fn handle_err(&mut self) { + self.0.status_flags = StatusFlags::empty(); + self.0.has_results = false; + self.0.ok_packet = None; + } + + fn more_results_exists(&self) -> bool { + self.0 + .status_flags + .contains(StatusFlags::SERVER_MORE_RESULTS_EXISTS) + } + + fn perform_auth_switch(&mut self, auth_switch_request: AuthSwitchRequest<'_>) -> Result<()> { + if matches!( + auth_switch_request.auth_plugin(), + AuthPlugin::MysqlOldPassword + ) && self.0.opts.get_secure_auth() + { + return Err(DriverError(OldMysqlPasswordDisabled)); + } + + if matches!( + auth_switch_request.auth_plugin(), + AuthPlugin::Other(Cow::Borrowed(b"mysql_clear_password")) + ) && !self.0.opts.get_enable_cleartext_plugin() + { + return Err(DriverError(CleartextPluginDisabled)); + } + + self.0.nonce = auth_switch_request.plugin_data().to_vec(); + self.0.auth_plugin = auth_switch_request.auth_plugin().into_owned(); + let plugin_data = match self.0.auth_plugin { + ref x @ AuthPlugin::MysqlOldPassword => { + if self.0.opts.get_secure_auth() { + return Err(DriverError(OldMysqlPasswordDisabled)); + } + x.gen_data(self.0.opts.get_pass(), &self.0.nonce) + } + ref x @ AuthPlugin::MysqlNativePassword => { + x.gen_data(self.0.opts.get_pass(), &self.0.nonce) + } + ref x @ AuthPlugin::CachingSha2Password => { + x.gen_data(self.0.opts.get_pass(), &self.0.nonce) + } + ref x @ AuthPlugin::MysqlClearPassword => { + if !self.0.opts.get_enable_cleartext_plugin() { + return Err(DriverError(UnknownAuthPlugin( + "mysql_clear_password".into(), + ))); + } + + x.gen_data(self.0.opts.get_pass(), &self.0.nonce) + } + ref x @ AuthPlugin::Ed25519 => x.gen_data(self.0.opts.get_pass(), &self.0.nonce), + // For parsec at this point we need to send an empty packet first + ref _x @ AuthPlugin::MariadbParsec { .. } => None, + AuthPlugin::Other(_) => None, + }; + + if let Some(plugin_data) = plugin_data { + self.write_struct(&plugin_data.into_owned())?; + } else { + self.write_packet(&mut &[0_u8; 0][..])?; + } + + self.continue_auth(true) + } + + fn do_handshake(&mut self) -> Result<()> { + let payload = self.read_packet()?; + let handshake = ParseBuf(&payload).parse::(())?; + + if handshake.protocol_version() != 10u8 { + return Err(DriverError(UnsupportedProtocol( + handshake.protocol_version(), + ))); + } + + if !handshake + .capabilities() + .contains(CapabilityFlags::CLIENT_PROTOCOL_41) + { + return Err(DriverError(Protocol41NotSet)); + } + + self.handle_handshake(&handshake); + + if self.is_insecure() { + if let Some(ssl_opts) = self.0.opts.get_ssl_opts().cloned() { + if !self.has_capability(CapabilityFlags::CLIENT_SSL) { + return Err(DriverError(TlsNotSupported)); + } else { + self.do_ssl_request()?; + self.switch_to_ssl(ssl_opts)?; + } + } + } + + // Handshake scramble is always 21 bytes length (20 + zero terminator) + self.0.nonce = { + let mut nonce = Vec::from(handshake.scramble_1_ref()); + nonce.extend_from_slice(handshake.scramble_2_ref().unwrap_or(&[][..])); + // Trim zero terminator. Fill with zeroes if nonce + // is somehow smaller than 20 bytes (this matches the server behavior). + nonce.resize(20, 0); + nonce + }; + + // Allow only CachingSha2Password and MysqlNativePassword here + // because sha256_password is deprecated and other plugins won't + // appear here. + self.0.auth_plugin = match handshake.auth_plugin() { + Some(x @ AuthPlugin::CachingSha2Password) => x.into_owned(), + _ => AuthPlugin::MysqlNativePassword, + }; + + self.write_handshake_response()?; + self.continue_auth(false)?; + + if self.has_capability(CapabilityFlags::CLIENT_COMPRESS) { + self.switch_to_compressed(); + } + + Ok(()) + } + + fn switch_to_compressed(&mut self) { + self.stream_mut() + .codec_mut() + .compress(Compression::default()); + } + + fn get_client_flags(&self) -> CapabilityFlags { + let mut client_flags = CapabilityFlags::CLIENT_PROTOCOL_41 + | CapabilityFlags::CLIENT_SECURE_CONNECTION + | CapabilityFlags::CLIENT_LONG_PASSWORD + | CapabilityFlags::CLIENT_TRANSACTIONS + | CapabilityFlags::CLIENT_LOCAL_FILES + | CapabilityFlags::CLIENT_MULTI_STATEMENTS + | CapabilityFlags::CLIENT_MULTI_RESULTS + | CapabilityFlags::CLIENT_PS_MULTI_RESULTS + | CapabilityFlags::CLIENT_PLUGIN_AUTH + | (self.0.capability_flags & CapabilityFlags::CLIENT_LONG_FLAG); + if self.0.opts.get_compress().is_some() { + client_flags.insert(CapabilityFlags::CLIENT_COMPRESS); + } + if self.0.opts.get_connect_attrs().is_some() { + client_flags.insert(CapabilityFlags::CLIENT_CONNECT_ATTRS); + } + if let Some(db_name) = self.0.opts.get_db_name() { + if !db_name.is_empty() { + client_flags.insert(CapabilityFlags::CLIENT_CONNECT_WITH_DB); + } + } + if self.is_insecure() && self.0.opts.get_ssl_opts().is_some() { + client_flags.insert(CapabilityFlags::CLIENT_SSL); + } + client_flags | self.0.opts.get_additional_capabilities() + } + + /// Get MariaDB client capabilities. + /// + /// This function has to be enhanced if client supports more MariaDB features. + fn get_mariadb_client_flags(&self) -> MariadbCapabilities { + MariadbCapabilities::MARIADB_CLIENT_CACHE_METADATA + | MariadbCapabilities::MARIADB_CLIENT_STMT_BULK_OPERATIONS + | MariadbCapabilities::MARIADB_CLIENT_BULK_UNIT_RESULTS + } + + fn connect_attrs(&self) -> Option> { + if let Some(attrs) = self.0.opts.get_connect_attrs() { + let program_name = match attrs.get("program_name") { + Some(program_name) => program_name.clone(), + None => { + let arg0 = std::env::args_os().next(); + let arg0 = arg0.as_ref().map(|x| x.to_string_lossy()); + arg0.unwrap_or_else(|| "".into()).into_owned() + } + }; + + let mut attrs_to_send = HashMap::new(); + + attrs_to_send.insert("_client_name".into(), "rust-mysql-simple".into()); + attrs_to_send.insert("_client_version".into(), env!("CARGO_PKG_VERSION").into()); + attrs_to_send.insert("_os".into(), env!("CARGO_CFG_TARGET_OS").into()); + attrs_to_send.insert("_pid".into(), process::id().to_string()); + attrs_to_send.insert("_platform".into(), env!("CARGO_CFG_TARGET_ARCH").into()); + attrs_to_send.insert("program_name".into(), program_name); + + for (name, value) in attrs.clone() { + attrs_to_send.insert(name, value); + } + + Some(attrs_to_send) + } else { + None + } + } + + fn do_ssl_request(&mut self) -> Result<()> { + let charset = if self.server_version() >= (5, 5, 3) { + UTF8MB4_GENERAL_CI + } else { + UTF8_GENERAL_CI + }; + + let ssl_request = SslRequest::new( + self.get_client_flags(), + DEFAULT_MAX_ALLOWED_PACKET as u32, + charset as u8, + ); + self.write_struct(&ssl_request) + } + + fn write_handshake_response(&mut self) -> Result<()> { + let auth_data = self + .0 + .auth_plugin + .gen_data(self.0.opts.get_pass(), &self.0.nonce) + .map(|x| x.into_owned()); + + let handshake_response = HandshakeResponse::new( + auth_data.as_deref(), + self.0.server_version.unwrap_or((0, 0, 0)), + self.0.opts.get_user().map(str::as_bytes), + self.0.opts.get_db_name().map(str::as_bytes), + Some(self.0.auth_plugin.clone()), + self.0.capability_flags, + self.connect_attrs(), + self.0 + .opts + .get_max_allowed_packet() + .unwrap_or(DEFAULT_MAX_ALLOWED_PACKET) as u32, + ) + .with_mariadb_ext_capabilities(self.0.mariadb_ext_capabilities); + + let mut buf = get_buffer(); + handshake_response.serialize(buf.as_mut()); + self.write_packet(&mut &*buf) + } + + fn continue_auth(&mut self, auth_switched: bool) -> Result<()> { + match self.0.auth_plugin { + AuthPlugin::CachingSha2Password => { + self.continue_caching_sha2_password_auth(auth_switched)?; + Ok(()) + } + AuthPlugin::MysqlNativePassword | AuthPlugin::MysqlOldPassword => { + self.continue_mysql_native_password_auth(auth_switched)?; + Ok(()) + } + AuthPlugin::MysqlClearPassword => { + if !self.0.opts.get_enable_cleartext_plugin() { + return Err(DriverError(CleartextPluginDisabled)); + } + self.continue_mysql_native_password_auth(auth_switched)?; + Ok(()) + } + AuthPlugin::Ed25519 => { + self.continue_ed25519_auth(auth_switched)?; + Ok(()) + } + AuthPlugin::MariadbParsec { .. } => { + self.continue_parsec_auth(auth_switched)?; + Ok(()) + } + AuthPlugin::Other(ref name) => { + let plugin_name = String::from_utf8_lossy(name).into(); + Err(DriverError(UnknownAuthPlugin(plugin_name))) + } + } + } + + fn continue_mysql_native_password_auth(&mut self, auth_switched: bool) -> Result<()> { + let payload = self.read_packet()?; + + match payload[0] { + // auth ok + 0x00 => self.handle_ok::(&payload).map(drop), + // auth switch + 0xfe if !auth_switched => { + let auth_switch = if payload.len() > 1 { + ParseBuf(&payload).parse(())? + } else { + let _ = ParseBuf(&payload).parse::(())?; + // we'll map OldAuthSwitchRequest to an AuthSwitchRequest with mysql_old_password plugin. + AuthSwitchRequest::new("mysql_old_password".as_bytes(), &*self.0.nonce) + .into_owned() + }; + self.perform_auth_switch(auth_switch) + } + _ => Err(DriverError(UnexpectedPacket)), + } + } + + fn continue_caching_sha2_password_auth(&mut self, auth_switched: bool) -> Result<()> { + let payload = self.read_packet()?; + + match payload[0] { + 0x00 => { + // ok packet for empty password + Ok(()) + } + 0x01 => match payload[1] { + 0x03 => { + let payload = self.read_packet()?; + self.handle_ok::(&payload).map(drop) + } + 0x04 => { + if !self.is_insecure() || self.is_socket() { + let mut pass = self.0.opts.get_pass().map(Vec::from).unwrap_or_default(); + pass.push(0); + self.write_packet(&mut pass.as_slice())?; + } else { + let configured_key = self + .0 + .opts + .get_server_public_key_path() + .map(std::fs::read) + .transpose()?; + let server_payload; + let key = if let Some(key) = configured_key.as_deref() { + key + } else { + self.write_packet(&mut &[0x02][..])?; + server_payload = self.read_packet()?; + &server_payload[1..] + }; + let mut pass = self.0.opts.get_pass().map(Vec::from).unwrap_or_default(); + pass.push(0); + for (i, c) in pass.iter_mut().enumerate() { + *(c) ^= self.0.nonce[i % self.0.nonce.len()]; + } + let encrypted_pass = crypto::encrypt(&pass, key); + self.write_packet(&mut encrypted_pass.as_slice())?; + } + + let payload = self.read_packet()?; + self.handle_ok::(&payload).map(drop) + } + _ => Err(DriverError(UnexpectedPacket)), + }, + 0xfe if !auth_switched => { + let auth_switch_request = ParseBuf(&payload).parse(())?; + self.perform_auth_switch(auth_switch_request) + } + _ => Err(DriverError(UnexpectedPacket)), + } + } + + fn continue_ed25519_auth(&mut self, auth_switched: bool) -> Result<()> { + let payload = self.read_packet()?; + match payload[0] { + // ok packet for empty password + 0x00 => Ok(()), + 0xfe if !auth_switched => { + let auth_switch_request = ParseBuf(&payload).parse(())?; + self.perform_auth_switch(auth_switch_request) + } + _ => Err(DriverError(UnexpectedPacket)), + } + } + + fn continue_parsec_auth(&mut self, auth_switched: bool) -> Result<()> { + let packet = self.read_packet()?; + // Normally we need to skip escaping 0x01 byte. But in first parsec implementations, server did not send it. + let mut payload = &packet[0..]; + if !packet.is_empty() && packet[0] == 0x01 { + payload = &packet[1..]; + } + // At this point in future, when it will be possible for parsec to be default authentication method, + // we can have authentication switch request. The other possible option here(and for now the only option) - + // ext-salt packet. + if !payload.is_empty() && payload[0] == 0xfe && !auth_switched { + let auth_switch_request = ParseBuf(payload).parse(())?; + self.perform_auth_switch(auth_switch_request) + } else { + // Letting parser function decide if all is fine with the packet + self.0 + .auth_plugin + .read_add_data(payload) + .ok_or(DriverError(crate::DriverError::InvalidParsecSalt))?; + // Now generating response. + let plugin_data = self + .0 + .auth_plugin + .gen_data(self.0.opts.get_pass(), &self.0.nonce) + .unwrap(); + + self.write_struct(&plugin_data.into_owned())?; + // After client response, server will send either ok or error. + let payload = self.read_packet()?; + match payload[0] { + 0x00 => self.handle_ok::(&payload).map(drop), + _ => Err(DriverError(UnexpectedPacket)), + } + } + } + + fn reset_seq_id(&mut self) { + self.stream_mut().codec_mut().reset_seq_id(); + } + + fn sync_seq_id(&mut self) { + self.stream_mut().codec_mut().sync_seq_id(); + } + + fn write_command_raw(&mut self, cmd: &T) -> Result<()> { + let mut buf = get_buffer(); + cmd.serialize(buf.as_mut()); + self.reset_seq_id(); + debug_assert!(!buf.is_empty()); + self.0.last_command = buf[0]; + self.write_packet(&mut &*buf) + } + + fn write_command(&mut self, cmd: Command, data: &[u8]) -> Result<()> { + let mut buf = get_buffer(); + buf.as_mut().put_u8(cmd as u8); + buf.as_mut().extend_from_slice(data); + + self.reset_seq_id(); + self.0.last_command = buf[0]; + self.write_packet(&mut &*buf) + } + + fn send_long_data(&mut self, stmt_id: u32, params: &[Value]) -> Result<()> { + for (i, value) in params.iter().enumerate() { + if let Bytes(bytes) = value { + let chunks = bytes.chunks(MAX_PAYLOAD_LEN - 6); + let chunks = chunks.chain(if bytes.is_empty() { + Some(&[][..]) + } else { + None + }); + for chunk in chunks { + let cmd = ComStmtSendLongData::new(stmt_id, i as u16, Cow::Borrowed(chunk)); + self.write_command_raw(&cmd)?; + } + } + } + + Ok(()) + } + + fn _execute(&mut self, stmt: &Statement, params: Params) -> Result { + let params = params + .into_values(stmt.named_params.as_deref()) + .map_err(crate::DriverError::from)?; + + if usize::from(stmt.num_params()) != params.len() { + return Err(DriverError(MismatchedStmtParams( + stmt.num_params(), + params.len(), + ))); + } + + let (exec_request, as_long_data) = + ComStmtExecuteRequestBuilder::new(stmt.id()).build(¶ms); + + if as_long_data { + self.send_long_data(stmt.id(), ¶ms)?; + } + + self.write_command_raw(&exec_request)?; + self.handle_result_set() + } + + /// Bulk-executes the given statement. Query results will be ignored. + fn _execute_bulk(&mut self, stmt: &Statement, params: I) -> Result<()> + where + I: IntoIterator, + P: Into, + { + // TODO: MariadbCapabilities::MARIADB_CLIENT_BULK_UNIT_RESULTS + + let mut builder = ComStmtBulkExecuteRequestBuilder::new( + stmt.id(), + self.stream_ref().codec().max_allowed_packet, + ) + .with_named_params(stmt.named_params.as_deref()); + + for command in builder.build_params_iter(params) { + let command = command.map_err(crate::DriverError::from)?; + self.write_command_raw(&command)?; + let meta = self.handle_result_set()?; + let meta = meta.into_statement_meta(self, stmt); + // drop query result + let _ = QueryResult::<'_, '_, '_, Binary>::new(ConnMut::Mut(self), meta); + } + + Ok(()) + } + + fn _start_transaction(&mut self, tx_opts: TxOpts) -> Result<()> { + if let Some(i_level) = tx_opts.isolation_level() { + self.query_drop(format!("SET TRANSACTION ISOLATION LEVEL {}", i_level))?; + } + if let Some(mode) = tx_opts.access_mode() { + let supported = match (self.0.server_version, self.0.mariadb_server_version) { + (Some(ref version), _) if *version >= (5, 6, 5) => true, + (_, Some(ref version)) if *version >= (10, 0, 0) => true, + _ => false, + }; + if !supported { + return Err(DriverError(ReadOnlyTransNotSupported)); + } + match mode { + AccessMode::ReadOnly => self.query_drop("SET TRANSACTION READ ONLY")?, + AccessMode::ReadWrite => self.query_drop("SET TRANSACTION READ WRITE")?, + } + } + if tx_opts.with_consistent_snapshot() { + self.query_drop("START TRANSACTION WITH CONSISTENT SNAPSHOT") + .unwrap(); + } else { + self.query_drop("START TRANSACTION")?; + }; + Ok(()) + } + + fn send_local_infile(&mut self, file_name: &[u8]) -> Result> { + { + let mut buffer = [0_u8; LocalInfile::BUFFER_SIZE]; + let maybe_handler = self + .0 + .local_infile_handler + .clone() + .or_else(|| self.0.opts.get_local_infile_handler().cloned()); + let mut local_infile = LocalInfile::new(&mut buffer, self); + if let Some(handler) = maybe_handler { + // Unwrap won't panic because we have exclusive access to `self` and this + // method is not re-entrant, because `LocalInfile` does not expose the + // connection. + let handler_fn = &mut *handler.0.lock()?; + handler_fn(file_name, &mut local_infile)?; + } + local_infile.flush()?; + } + self.write_packet(&mut &[][..])?; + let payload = self.read_packet()?; + let ok = self.handle_ok::(&payload)?; + Ok(ok.into_owned()) + } + + fn handle_result_set(&mut self) -> Result { + if self.more_results_exists() { + self.sync_seq_id(); + } + + let pld = self.read_packet()?; + match pld[0] { + 0x00 => { + let ok = self.handle_ok::(&pld)?; + Ok(ResultSetInfo::Empty(ok.into_owned())) + } + 0xfb => match self.send_local_infile(&pld[1..]) { + Ok(ok) => Ok(ResultSetInfo::Empty(ok)), + Err(err) => Err(err), + }, + _ => { + let mut reader = &pld[..]; + let column_count = reader.read_lenenc_int()?; + + let mut columns: Vec = Vec::new(); + + // https://jira.mariadb.org/browse/MDEV-19237 + let output = if !(self + .has_mariadb_capability(MariadbCapabilities::MARIADB_CLIENT_CACHE_METADATA) + && reader.first().copied() == Some(0x00)) + { + columns.reserve(column_count as usize); + for _ in 0..column_count { + let pld = self.read_packet()?; + let column = ParseBuf(&pld).parse(())?; + columns.push(column); + } + + ResultSetInfo::NonEmptyWithMeta(columns) + } else { + ResultSetInfo::NonEmptySkipMeta + }; + + // skip eof packet + self.drop_packet()?; + self.0.has_results = column_count > 0; + + Ok(output) + } + } + } + + fn _query(&mut self, query: &str) -> Result { + self.write_command(Command::COM_QUERY, query.as_bytes())?; + let info = self.handle_result_set()?; + let meta = info.into_query_meta(); + Ok(meta) + } + + /// Executes [`COM_PING`](https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_ping.html) + /// on `Conn`. Return `true` on success or `false` on error. + pub fn ping(&mut self) -> Result<(), Error> { + self.write_command(Command::COM_PING, &[])?; + self.drop_packet() + } + + /// Executes [`COM_INIT_DB`](https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_com_init_db.html) + /// on `Conn`. + pub fn select_db(&mut self, schema: &str) -> Result<(), Error> { + self.write_command(Command::COM_INIT_DB, schema.as_bytes())?; + self.drop_packet() + } + + /// Starts new transaction with provided options. + /// `readonly` is only available since MySQL 5.6.5. + pub fn start_transaction(&mut self, tx_opts: TxOpts) -> Result> { + self._start_transaction(tx_opts)?; + Ok(Transaction::new(self.into())) + } + + fn _true_prepare(&mut self, query: &[u8]) -> Result { + self.write_command(Command::COM_STMT_PREPARE, query)?; + let pld = self.read_packet()?; + let mut stmt = ParseBuf(&pld).parse::(self.connection_id())?; + if stmt.num_params() > 0 { + let mut params: Vec = Vec::with_capacity(stmt.num_params() as usize); + for _ in 0..stmt.num_params() { + let pld = self.read_packet()?; + params.push(ParseBuf(&pld).parse(())?); + } + stmt = stmt.with_params(Some(params)); + self.drop_packet()?; + } + if stmt.num_columns() > 0 { + let mut columns: Vec = Vec::with_capacity(stmt.num_columns() as usize); + for _ in 0..stmt.num_columns() { + let pld = self.read_packet()?; + columns.push(ParseBuf(&pld).parse(())?); + } + stmt = stmt.with_columns(Some(columns)); + self.drop_packet()?; + } + Ok(stmt) + } + + fn _prepare(&mut self, query: &[u8]) -> Result> { + if let Some(entry) = self.0.stmt_cache.by_query(query) { + return Ok(entry.stmt.clone()); + } + + let inner_st = Arc::new(self._true_prepare(query)?); + + if let Some(old_stmt) = self + .0 + .stmt_cache + .put(Arc::new(query.into()), inner_st.clone()) + { + self.close(Statement::new(old_stmt, None))?; + } + + Ok(inner_st) + } + + fn connect(&mut self) -> Result<()> { + if self.0.connected { + return Ok(()); + } + self.do_handshake() + .and_then(|_| match self.0.opts.get_max_allowed_packet() { + Some(x) => Ok(x), + None => Ok(from_value_opt::( + self.get_system_var("max_allowed_packet")?.unwrap_or(NULL), + ) + .unwrap_or(0)), + }) + .and_then(|max_allowed_packet| { + if max_allowed_packet == 0 { + Err(DriverError(SetupError)) + } else { + self.stream_mut().codec_mut().max_allowed_packet = max_allowed_packet; + self.0.connected = true; + Ok(()) + } + }) + } + + fn get_system_var(&mut self, name: &str) -> Result> { + self.query_first(format!("SELECT @@{}", name)) + } + + fn next_row_packet(&mut self) -> Result> { + if !self.0.has_results { + return Ok(None); + } + + let pld = self.read_packet()?; + + if self.has_capability(CapabilityFlags::CLIENT_DEPRECATE_EOF) { + if pld[0] == 0xfe && pld.len() < MAX_PAYLOAD_LEN { + self.0.has_results = false; + self.handle_ok::(&pld)?; + return Ok(None); + } + } else if pld[0] == 0xfe && pld.len() < 8 { + self.0.has_results = false; + self.handle_ok::(&pld)?; + return Ok(None); + } + + Ok(Some(pld)) + } + + fn has_stmt(&self, query: &[u8]) -> bool { + self.0.stmt_cache.contains_query(query) + } + + /// Sets a callback to handle requests for local files. These are + /// caused by using `LOAD DATA LOCAL INFILE` queries. The + /// callback is passed the filename, and a `Write`able object + /// to receive the contents of that file. + /// Specifying `None` will reset the handler to the one specified + /// in the `Opts` for this connection. + pub fn set_local_infile_handler(&mut self, handler: Option) { + self.0.local_infile_handler = handler; + } + + pub fn no_backslash_escape(&self) -> bool { + self.0 + .status_flags + .contains(StatusFlags::SERVER_STATUS_NO_BACKSLASH_ESCAPES) + } + + #[cfg(feature = "binlog")] + fn register_as_slave(&mut self, server_id: u32) -> Result<()> { + use mysql_common::packets::ComRegisterSlave; + + self.query_drop("SET @master_binlog_checksum='ALL'")?; + self.write_command_raw(&ComRegisterSlave::new(server_id))?; + + // Server will respond with OK. + self.read_packet()?; + + Ok(()) + } + + #[cfg(feature = "binlog")] + fn request_binlog(&mut self, request: BinlogRequest<'_>) -> Result<()> { + self.register_as_slave(request.server_id())?; + self.write_command_raw(&request.as_cmd())?; + Ok(()) + } + + /// Turns this connection into a binlog stream. + /// + /// You can use `SHOW BINARY LOGS` to get the current log file and position from the master. + /// If the request's `filename` is empty, the server will send the binlog-stream + /// of the first known binlog. + #[cfg(feature = "binlog")] + #[cfg_attr(docsrs, doc(cfg(feature = "binlog")))] + pub fn get_binlog_stream(mut self, request: BinlogRequest<'_>) -> Result { + self.request_binlog(request)?; + Ok(BinlogStream::new(self)) + } + + fn cleanup_for_pool(&mut self) -> Result<()> { + self.set_local_infile_handler(None); + if self.0.reset_upon_return { + self.reset()?; + } + + self.0.reset_upon_return = self.0.opts.get_pool_opts().reset_connection(); + + Ok(()) + } +} + +#[cfg(unix)] +impl AsRawFd for Conn { + fn as_raw_fd(&self) -> RawFd { + self.stream_ref().get_ref().as_raw_fd() + } +} + +impl Queryable for Conn { + fn query_iter>(&mut self, query: T) -> Result> { + let meta = self._query(query.as_ref())?; + Ok(QueryResult::new(ConnMut::Mut(self), meta)) + } + + fn prep>(&mut self, query: T) -> Result { + let query = query.as_ref(); + let parsed = ParsedNamedParams::parse(query.as_bytes())?; + let named_params: Vec> = + parsed.params().iter().map(|param| param.to_vec()).collect(); + let named_params = if named_params.is_empty() { + None + } else { + Some(named_params) + }; + self._prepare(parsed.borrow().query()) + .map(|inner| Statement::new(inner, named_params)) + } + + fn close(&mut self, stmt: Statement) -> Result<()> { + self.0.stmt_cache.remove(stmt.id()); + let cmd = ComStmtClose::new(stmt.id()); + self.write_command_raw(&cmd) + } + + fn exec_iter(&mut self, stmt: S, params: P) -> Result> + where + S: AsStatement, + P: Into, + { + let statement = stmt.as_statement(self)?; + let info = self._execute(&statement, params.into())?; + let meta = info.into_statement_meta(&*self, &statement); + Ok(QueryResult::new(ConnMut::Mut(self), meta)) + } + + fn exec_batch(&mut self, stmt: S, params: I) -> Result<()> + where + Self: Sized, + S: AsStatement, + P: Into, + I: IntoIterator, + { + let stmt = stmt.as_statement(self)?; + if self.has_mariadb_capability(MariadbCapabilities::MARIADB_CLIENT_STMT_BULK_OPERATIONS) + && stmt.num_params() > 0 + { + self._execute_bulk(&stmt, params)?; + } else { + for params in params { + self.exec_drop(stmt.as_ref(), params)?; + } + } + Ok(()) + } +} + +impl Drop for Conn { + fn drop(&mut self) { + let stmt_cache = mem::replace(&mut self.0.stmt_cache, StmtCache::new(0)); + + for (_, entry) in stmt_cache.into_iter() { + let _ = self.close(Statement::new(entry.stmt, None)); + } + + if self.0.stream.is_some() { + let _ = self.write_command(Command::COM_QUIT, &[]); + } + } +} + +#[cfg(test)] +#[allow(non_snake_case)] +mod test { + mod my_conn { + use std::{ + collections::HashMap, + io::Write, + process, + sync::mpsc::{channel, sync_channel}, + thread::spawn, + time::Duration, + }; + + #[cfg(feature = "binlog")] + use mysql_common::{binlog::events::EventData, packets::binlog_request::BinlogRequest}; + use mysql_common::{ + constants::MariadbCapabilities, + params::{MissingNamedParameterError, ParamsConfusionError, ParamsError}, + }; + use rand::Rng; + #[cfg(feature = "time")] + use time::PrimitiveDateTime; + + use crate::{ + conn::ConnInner, + from_row, from_value, params, + prelude::*, + test_misc::get_opts, + Conn, + Error::DriverError, + LocalInfileHandler, Opts, OptsBuilder, Pool, TxOpts, + Value::{self, Bytes, Date, Float, Int, NULL}, + }; + + fn get_system_variable(conn: &mut Conn, name: &str) -> T + where + T: FromValue, + { + conn.query_first::<(String, T), _>(format!("show variables like '{}'", name)) + .unwrap() + .unwrap() + .1 + } + + #[test] + fn should_connect() { + let mut conn = Conn::new(get_opts()).unwrap(); + + let mode: String = conn + .query_first("SELECT @@GLOBAL.sql_mode") + .unwrap() + .unwrap(); + assert!(mode.contains("TRADITIONAL")); + assert!(conn.ping().is_ok()); + + if crate::test_misc::test_compression() { + assert!(format!("{:?}", conn.0.stream).contains("Compression")); + } + + if crate::test_misc::test_ssl() { + assert!(!conn.is_insecure()); + } + } + + #[test] + fn mysql_async_issue_107() -> crate::Result<()> { + let mut conn = Conn::new(get_opts())?; + conn.query_drop( + r"CREATE TEMPORARY TABLE mysql.issue ( + a BIGINT(20) UNSIGNED, + b VARBINARY(16), + c BINARY(32), + d BIGINT(20) UNSIGNED, + e BINARY(32) + )", + )?; + conn.query_drop( + r"INSERT INTO mysql.issue VALUES ( + 0, + 0xC066F966B0860000, + 0x7939DA98E524C5F969FC2DE8D905FD9501EBC6F20001B0A9C941E0BE6D50CF44, + 0, + '' + ), ( + 1, + '', + 0x076311DF4D407B0854371BA13A5F3FB1A4555AC22B361375FD47B263F31822F2, + 0, + '' + )", + )?; + + let q = "SELECT b, c, d, e FROM mysql.issue"; + let result = conn.query_iter(q)?; + + let loaded_structs = result + .map(|row| crate::from_row::<(Vec, Vec, u64, Vec)>(row.unwrap())) + .collect::>(); + + assert_eq!(loaded_structs.len(), 2); + + Ok(()) + } + + #[test] + fn query_traits() -> Result<(), Box> { + macro_rules! test_query { + ($conn : expr) => { + "CREATE TABLE IF NOT EXISTS tmplak (a INT)" + .run($conn) + .unwrap(); + "DELETE FROM tmplak".run($conn).unwrap(); + + "INSERT INTO tmplak (a) VALUES (?)" + .with((42,)) + .run($conn) + .unwrap(); + + "INSERT INTO tmplak (a) VALUES (?)" + .with((43..=44).map(|x| (x,))) + .batch($conn)?; + + let first: Option = "SELECT a FROM tmplak LIMIT 1".first($conn).unwrap(); + assert_eq!(first, Some(42), "first text"); + + let first: Option = "SELECT a FROM tmplak LIMIT 1" + .with(()) + .first($conn) + .unwrap(); + assert_eq!(first, Some(42), "first bin"); + + let count = "SELECT a FROM tmplak".run($conn).unwrap().count(); + assert_eq!(count, 3, "run text"); + + let count = "SELECT a FROM tmplak".with(()).run($conn).unwrap().count(); + assert_eq!(count, 3, "run bin"); + + let all: Vec = "SELECT a FROM tmplak".fetch($conn).unwrap(); + assert_eq!(all, vec![42, 43, 44], "fetch text"); + + let all: Vec = "SELECT a FROM tmplak".with(()).fetch($conn).unwrap(); + assert_eq!(all, vec![42, 43, 44], "fetch bin"); + + let mapped = "SELECT a FROM tmplak".map($conn, |x: u8| x + 1).unwrap(); + assert_eq!(mapped, vec![43, 44, 45], "map text"); + + let mapped = "SELECT a FROM tmplak" + .with(()) + .map($conn, |x: u8| x + 1) + .unwrap(); + assert_eq!(mapped, vec![43, 44, 45], "map bin"); + + let sum = "SELECT a FROM tmplak" + .fold($conn, 0_u8, |acc, x: u8| acc + x) + .unwrap(); + assert_eq!(sum, 42 + 43 + 44, "fold text"); + + let sum = "SELECT a FROM tmplak" + .with(()) + .fold($conn, 0_u8, |acc, x: u8| acc + x) + .unwrap(); + assert_eq!(sum, 42 + 43 + 44, "fold bin"); + + "DROP TABLE tmplak".run($conn).unwrap(); + }; + } + + let mut conn = Conn::new(get_opts())?; + + let mut tx = conn.start_transaction(TxOpts::default())?; + test_query!(&mut tx); + tx.rollback()?; + + test_query!(&mut conn); + + let pool = Pool::new(get_opts())?; + let mut pooled_conn = pool.get_conn()?; + + let mut tx = pool.start_transaction(TxOpts::default())?; + test_query!(&mut tx); + tx.rollback()?; + + test_query!(&mut pooled_conn); + + Ok(()) + } + + #[test] + #[should_panic(expected = "Could not connect to address")] + fn should_fail_on_wrong_socket_path() { + let opts = OptsBuilder::from_opts(get_opts()).socket(Some("/foo/bar/baz")); + let _ = Conn::new(opts).unwrap(); + } + + #[test] + fn should_fallback_to_tcp_if_cant_switch_to_socket() { + let mut opts = Opts::from(get_opts()); + opts.0.injected_socket = Some("/foo/bar/baz".into()); + let _ = Conn::new(opts).unwrap(); + } + + #[test] + fn should_connect_with_database() { + const DB_NAME: &str = "mysql"; + + let opts = OptsBuilder::from_opts(get_opts()).db_name(Some(DB_NAME)); + + let mut conn = Conn::new(opts).unwrap(); + + let db_name: String = conn.query_first("SELECT DATABASE()").unwrap().unwrap(); + assert_eq!(db_name, DB_NAME); + } + + #[test] + fn should_connect_by_hostname() { + let opts = OptsBuilder::from_opts(get_opts()).ip_or_hostname(Some("localhost")); + let mut conn = Conn::new(opts).unwrap(); + assert!(conn.ping().is_ok()); + } + + #[test] + fn should_select_db() { + const DB_NAME: &str = "t_select_db"; + + let mut conn = Conn::new(get_opts()).unwrap(); + conn.query_drop(format!("CREATE DATABASE IF NOT EXISTS {}", DB_NAME)) + .unwrap(); + assert!(conn.select_db(DB_NAME).is_ok()); + + let db_name: String = conn.query_first("SELECT DATABASE()").unwrap().unwrap(); + assert_eq!(db_name, DB_NAME); + + conn.query_drop(format!("DROP DATABASE {}", DB_NAME)) + .unwrap(); + } + + #[test] + fn should_execute_queries_and_parse_results() { + type TestRow = (String, String, String, String, String, String); + + const CREATE_QUERY: &str = r"CREATE TEMPORARY TABLE mysql.tbl + (id SERIAL, a TEXT, b INT, c INT UNSIGNED, d DATE, e FLOAT)"; + const INSERT_QUERY_1: &str = r"INSERT + INTO mysql.tbl(a, b, c, d, e) + VALUES ('hello', -123, 123, '2014-05-05', 123.123)"; + const INSERT_QUERY_2: &str = r"INSERT + INTO mysql.tbl(a, b, c, d, e) + VALUES ('world', -321, 321, '2014-06-06', 321.321)"; + + let mut conn = Conn::new(get_opts()).unwrap(); + + conn.query_drop(CREATE_QUERY).unwrap(); + assert_eq!(conn.affected_rows(), 0); + assert_eq!(conn.last_insert_id(), 0); + + conn.query_drop(INSERT_QUERY_1).unwrap(); + assert_eq!(conn.affected_rows(), 1); + assert_eq!(conn.last_insert_id(), 1); + + conn.query_drop(INSERT_QUERY_2).unwrap(); + assert_eq!(conn.affected_rows(), 1); + assert_eq!(conn.last_insert_id(), 2); + + conn.query_drop("SELECT * FROM nonexistent").unwrap_err(); + conn.query_iter("SELECT * FROM mysql.tbl").unwrap(); // Drop::drop for QueryResult + + conn.query_drop("UPDATE mysql.tbl SET a = 'foo'").unwrap(); + assert_eq!(conn.affected_rows(), 2); + assert_eq!(conn.last_insert_id(), 0); + + assert!(conn + .query_first::("SELECT * FROM mysql.tbl WHERE a = 'bar'") + .unwrap() + .is_none()); + + let rows: Vec = conn.query("SELECT * FROM mysql.tbl").unwrap(); + assert_eq!( + rows, + vec![ + ( + "1".into(), + "foo".into(), + "-123".into(), + "123".into(), + "2014-05-05".into(), + "123.123".into() + ), + ( + "2".into(), + "foo".into(), + "-321".into(), + "321".into(), + "2014-06-06".into(), + "321.321".into() + ) + ] + ); + } + + #[test] + fn should_parse_large_text_result() { + let mut conn = Conn::new(get_opts()).unwrap(); + let value: Value = conn + .query_first("SELECT REPEAT('A', 20000000)") + .unwrap() + .unwrap(); + assert_eq!( + value, + Bytes(std::iter::repeat_n(b'A', 20_000_000).collect()) + ); + } + + #[test] + fn should_execute_statements_and_parse_results() { + const CREATE_QUERY: &str = r"CREATE TEMPORARY TABLE + mysql.tbl (a TEXT, b INT, c INT UNSIGNED, d DATE, e FLOAT)"; + const INSERT_STMT: &str = r"INSERT + INTO mysql.tbl (a, b, c, d, e) + VALUES (?, ?, ?, ?, ?)"; + + type RowType = (Value, Value, Value, Value, Value); + + let row1 = ( + Bytes(b"hello".to_vec()), + Int(-123_i64), + Int(123_i64), + Date(2014_u16, 5_u8, 5_u8, 0_u8, 0_u8, 0_u8, 0_u32), + Float(123.123_f32), + ); + let row2 = (Bytes(b"".to_vec()), NULL, NULL, NULL, Float(321.321_f32)); + + let mut conn = Conn::new(get_opts()).unwrap(); + conn.query_drop(CREATE_QUERY).unwrap(); + + let insert_stmt = conn.prep(INSERT_STMT).unwrap(); + assert_eq!(insert_stmt.connection_id(), conn.connection_id()); + conn.exec_drop( + &insert_stmt, + ( + from_value::(row1.0.clone()), + from_value::(row1.1.clone()), + from_value::(row1.2.clone()), + from_value::(row1.3.clone()), + from_value::(row1.4.clone()), + ), + ) + .unwrap(); + conn.exec_drop( + &insert_stmt, + ( + from_value::(row2.0.clone()), + row2.1.clone(), + row2.2.clone(), + row2.3.clone(), + from_value::(row2.4.clone()), + ), + ) + .unwrap(); + + let select_stmt = conn.prep("SELECT * from mysql.tbl").unwrap(); + let rows: Vec = conn.exec(&select_stmt, ()).unwrap(); + + assert_eq!(rows, vec![row1, row2]); + } + + #[test] + fn should_parse_large_binary_result() { + let mut conn = Conn::new(get_opts()).unwrap(); + let stmt = conn.prep("SELECT REPEAT('A', 20000000)").unwrap(); + let value: Value = conn.exec_first(&stmt, ()).unwrap().unwrap(); + assert_eq!( + value, + Bytes(std::iter::repeat_n(b'A', 20_000_000).collect()) + ); + } + + #[test] + fn manually_closed_stmt() { + let opts = get_opts().stmt_cache_size(1); + let mut conn = Conn::new(opts).unwrap(); + let stmt = conn.prep("SELECT 1").unwrap(); + conn.exec_drop(&stmt, ()).unwrap(); + conn.close(stmt).unwrap(); + let stmt = conn.prep("SELECT 1").unwrap(); + conn.exec_drop(&stmt, ()).unwrap(); + conn.close(stmt).unwrap(); + let stmt = conn.prep("SELECT 2").unwrap(); + conn.exec_drop(&stmt, ()).unwrap(); + } + + #[test] + fn should_start_commit_and_rollback_transactions() { + let mut conn = Conn::new(get_opts()).unwrap(); + conn.query_drop( + "CREATE TEMPORARY TABLE mysql.tbl(id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, a INT)", + ) + .unwrap(); + conn.start_transaction(TxOpts::default()) + .map(|mut t| { + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(1)").unwrap(); + assert_eq!(t.last_insert_id(), Some(1)); + assert_eq!(t.affected_rows(), 1); + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(2)").unwrap(); + t.commit().unwrap(); + }) + .unwrap(); + assert_eq!( + conn.query_iter("SELECT COUNT(a) from mysql.tbl") + .unwrap() + .next() + .unwrap() + .unwrap() + .unwrap(), + vec![Bytes(b"2".to_vec())] + ); + conn.start_transaction(TxOpts::default()) + .map(|mut t| { + t.query_drop("INSERT INTO tbl2(a) VALUES(1)").unwrap_err(); + // implicit rollback + }) + .unwrap(); + assert_eq!( + conn.query_iter("SELECT COUNT(a) from mysql.tbl") + .unwrap() + .next() + .unwrap() + .unwrap() + .unwrap(), + vec![Bytes(b"2".to_vec())] + ); + conn.start_transaction(TxOpts::default()) + .map(|mut t| { + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(1)").unwrap(); + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(2)").unwrap(); + t.rollback().unwrap(); + }) + .unwrap(); + assert_eq!( + conn.query_iter("SELECT COUNT(a) from mysql.tbl") + .unwrap() + .next() + .unwrap() + .unwrap() + .unwrap(), + vec![Bytes(b"2".to_vec())] + ); + let mut tx = conn.start_transaction(TxOpts::default()).unwrap(); + tx.exec_drop("INSERT INTO mysql.tbl(a) VALUES(?)", (3,)) + .unwrap(); + tx.exec_drop("INSERT INTO mysql.tbl(a) VALUES(?)", (4,)) + .unwrap(); + tx.commit().unwrap(); + assert_eq!( + conn.query_iter("SELECT COUNT(a) from mysql.tbl") + .unwrap() + .next() + .unwrap() + .unwrap() + .unwrap(), + vec![Bytes(b"4".to_vec())] + ); + let mut tx = conn.start_transaction(TxOpts::default()).unwrap(); + tx.exec_drop("INSERT INTO mysql.tbl(a) VALUES(?)", (5,)) + .unwrap(); + tx.exec_drop("INSERT INTO mysql.tbl(a) VALUES(?)", (6,)) + .unwrap(); + drop(tx); + assert_eq!( + conn.query_first("SELECT COUNT(a) from mysql.tbl").unwrap(), + Some(4_usize), + ); + } + #[test] + fn should_handle_LOCAL_INFILE_with_custom_handler() { + let mut conn = Conn::new(get_opts()).unwrap(); + conn.query_drop("CREATE TEMPORARY TABLE mysql.tbl(a TEXT)") + .unwrap(); + conn.set_local_infile_handler(Some(LocalInfileHandler::new(|_, stream| { + let mut cell_data = vec![b'Z'; 65535]; + cell_data.push(b'\n'); + for _ in 0..1536 { + stream.write_all(&cell_data)?; + } + Ok(()) + }))); + match conn.query_drop("LOAD DATA LOCAL INFILE 'file_name' INTO TABLE mysql.tbl") { + Ok(_) => {} + Err(ref err) if format!("{}", err).find("not allowed").is_some() => { + return; + } + Err(err) => panic!("ERROR {}", err), + } + let count = conn + .query_iter("SELECT * FROM mysql.tbl") + .unwrap() + .map(|row| { + assert_eq!(from_row::<(Vec,)>(row.unwrap()).0.len(), 65535); + 1 + }) + .sum::(); + assert_eq!(count, 1536); + } + + #[test] + fn should_reset_connection() { + let mut conn = Conn::new(get_opts()).unwrap(); + conn.query_drop( + "CREATE TEMPORARY TABLE `mysql`.`test` \ + (`test` VARCHAR(255) NULL);", + ) + .unwrap(); + conn.query_drop("INSERT INTO `mysql`.`test` (`test`) VALUES ('foo');") + .unwrap(); + assert_eq!(conn.affected_rows(), 1); + conn.reset().unwrap(); + assert_eq!(conn.affected_rows(), 0); + conn.query_drop("SELECT * FROM `mysql`.`test`;") + .unwrap_err(); + } + + #[test] + fn should_change_user() -> crate::Result<()> { + /// Whether particular authentication plugin should be tested on the current database. + type ShouldRunFn = fn(bool, (u16, u16, u16)) -> bool; + /// Generates `CREATE USER` and `SET PASSWORD` statements + type CreateUserFn = fn(bool, (u16, u16, u16), &str) -> Vec; + + #[allow(clippy::type_complexity)] + const TEST_MATRIX: [(&str, ShouldRunFn, CreateUserFn); 5] = [ + ( + "mysql_old_password", + |is_mariadb, version| is_mariadb || version < (5, 7, 0), + |is_mariadb, version, pass| { + if is_mariadb { + vec![ + "CREATE USER '__mats'@'%' IDENTIFIED WITH mysql_old_password" + .into(), + "SET old_passwords=1".into(), + format!("ALTER USER '__mats'@'%' IDENTIFIED BY '{pass}'"), + "SET old_passwords=0".into(), + ] + } else if matches!(version, (5, 6, _)) { + vec![ + "CREATE USER '__mats'@'%' IDENTIFIED WITH mysql_old_password" + .into(), + format!("SET PASSWORD FOR '__mats'@'%' = OLD_PASSWORD('{pass}')"), + ] + } else { + vec![ + "CREATE USER '__mats'@'%'".into(), + format!("SET PASSWORD FOR '__mats'@'%' = PASSWORD('{pass}')"), + ] + } + }, + ), + ( + "mysql_native_password", + |is_mariadb, version| is_mariadb || version < (8, 4, 0), + |is_mariadb, version, pass| { + if is_mariadb { + vec![ + format!("CREATE USER '__mats'@'%' IDENTIFIED WITH mysql_native_password AS PASSWORD('{pass}')") + ] + } else if version < (8, 0, 0) { + vec![ + "CREATE USER '__mats'@'%' IDENTIFIED WITH mysql_native_password" + .into(), + "SET old_passwords = 0".into(), + format!("SET PASSWORD FOR '__mats'@'%' = PASSWORD('{pass}')"), + ] + } else { + vec![ + format!("CREATE USER '__mats'@'%' IDENTIFIED WITH mysql_native_password BY '{pass}'") + ] + } + }, + ), + ( + "caching_sha2_password", + |is_mariadb, version| !is_mariadb && version >= (5, 8, 0), + |_is_mariadb, _version, pass| { + vec![ + format!("CREATE USER '__mats'@'%' IDENTIFIED WITH caching_sha2_password BY '{pass}'") + ] + }, + ), + ( + "client_ed25519", + |is_mariadb, version| is_mariadb && version >= (10, 4, 0), + |_is_mariadb, _version, pass| { + vec![ + format!("CREATE USER '__mats'@'%' IDENTIFIED WITH ed25519 AS PASSWORD('{pass}')") + ] + }, + ), + ( + "parsec", + |is_mariadb, version| is_mariadb && version >= (11, 6, 0), + |_is_mariadb, _version, pass| { + vec![format!( + "CREATE USER '__mats'@'%' IDENTIFIED WITH parsec AS PASSWORD('{pass}')" + )] + }, + ), + ]; + + fn random_pass() -> String { + let mut rng = rand::rng(); + let mut pass = [0u8; 10]; + rng.fill_bytes(&mut pass); + IntoIterator::into_iter(pass) + .map(|x| ((x % (123 - 97)) + 97) as char) + .collect() + } + + let mut conn = Conn::new(get_opts()).unwrap(); + + assert_eq!( + conn.query_first::("SELECT @foo") + .unwrap() + .unwrap(), + Value::NULL + ); + + conn.query_drop("SET @foo = 'foo'").unwrap(); + + assert_eq!( + conn.query_first::("SELECT @foo") + .unwrap() + .unwrap(), + "foo", + ); + + conn.change_user(Default::default()).unwrap(); + assert_eq!( + conn.query_first::("SELECT @foo") + .unwrap() + .unwrap(), + Value::NULL + ); + + for (plugin, should_run, create_statements) in TEST_MATRIX { + dbg!(plugin); + let is_mariadb = conn.0.mariadb_server_version.is_some(); + let version = conn.server_version(); + + if should_run(is_mariadb, version) { + let pass = random_pass(); + + // (M)!50700 IF EXISTS: 5.7.0 (also on MariaDB) is minimum version that sees this clause + let statement = + "DROP USER /*!50700 IF EXISTS */ /*M!50700 IF EXISTS */ '__mats'"; + // No IF EXISTS before 5.7 so the query may fail otherwise + _ = conn.query_drop(dbg!(statement)); + + for statement in create_statements(is_mariadb, version, &pass) { + conn.query_drop(dbg!(statement)).unwrap(); + } + + let mut conn2 = Conn::new(get_opts().secure_auth(false)).unwrap(); + conn2 + .change_user( + crate::ChangeUserOpts::default() + .with_db_name(None) + .with_user(Some("__mats".into())) + .with_pass(Some(pass)), + ) + .unwrap(); + + let (db, user) = conn2 + .query_first::<(Option, String), _>("SELECT DATABASE(), USER();") + .unwrap() + .unwrap(); + assert_eq!(db, None); + assert!(user.starts_with("__mats")); + } + } + + Ok(()) + } + + #[test] + fn prep_exec() { + let mut conn = Conn::new(get_opts()).unwrap(); + + let stmt1 = conn.prep("SELECT :foo").unwrap(); + let stmt2 = conn.prep("SELECT :bar").unwrap(); + assert_eq!( + conn.exec::(&stmt1, params! { "foo" => "foo" }) + .unwrap(), + vec![String::from("foo")], + ); + assert_eq!( + conn.exec::(&stmt2, params! { "bar" => "bar" }) + .unwrap(), + vec![String::from("bar")], + ); + } + + #[test] + fn should_connect_via_socket_for_127_0_0_1() { + let opts = OptsBuilder::from_opts(get_opts()); + let mut conn = Conn::new(opts).unwrap(); + if conn.is_insecure() { + assert!( + conn.is_socket(), + "Did not reconnect via socket {:?}", + ( + conn.0.opts.get_prefer_socket(), + conn.0.opts.addr_is_loopback(), + conn.can_improved().and_then(|opts| { + opts.map(|opts| { + let mut new = crate::conn::Conn(Box::new(ConnInner::empty(opts))); + new.connect_stream().and_then(|_| { + new.connect()?; + Ok(new) + }) + }) + .transpose() + }), + ) + ); + } + } + + #[test] + fn should_connect_via_socket_localhost() { + let opts = OptsBuilder::from_opts(get_opts()).ip_or_hostname(Some("localhost")); + let mut conn = Conn::new(opts).unwrap(); + if conn.is_insecure() { + assert!( + conn.is_socket(), + "Did not reconnect via socket {:?}", + ( + conn.0.opts.get_prefer_socket(), + conn.0.opts.addr_is_loopback(), + conn.can_improved().and_then(|opts| { + opts.map(|opts| { + let mut new = crate::conn::Conn(Box::new(ConnInner::empty(opts))); + new.connect_stream().and_then(|_| { + new.connect()?; + Ok(new) + }) + }) + .transpose() + }), + ) + ); + } + } + + /// QueryResult::drop hangs on connectivity errors (see [blackbeam/rust-mysql-simple#306][1]). + /// + /// [1]: https://github.com/blackbeam/rust-mysql-simple/issues/306 + #[test] + fn issue_306() { + let (tx, rx) = channel::<()>(); + let handle = spawn(move || { + let mut c1 = Conn::new(get_opts()).unwrap(); + let c1_id = c1.connection_id(); + let mut c2 = Conn::new(get_opts()).unwrap(); + let query_result = c1.query_iter("DO 1; SELECT SLEEP(1); DO 2;").unwrap(); + c2.query_drop(format!("KILL {c1_id}")).unwrap(); + drop(c2); + drop(query_result); + tx.send(()).unwrap(); + }); + std::thread::sleep(Duration::from_secs(2)); + assert!(rx.try_recv().is_ok()); + handle.join().unwrap(); + } + + #[test] + fn reset_does_work() { + let mut c = Conn::new(get_opts()).unwrap(); + let cid = c.connection_id(); + c.query_drop("SET @foo = 'foo'").unwrap(); + assert_eq!( + c.query_first::("SELECT @foo").unwrap().unwrap(), + "foo", + ); + c.reset().unwrap(); + assert_eq!(cid, c.connection_id()); + assert_eq!( + c.query_first::("SELECT @foo").unwrap().unwrap(), + Value::NULL + ); + } + + #[test] + fn should_drop_multi_result_set() { + let opts = OptsBuilder::from_opts(get_opts()).db_name(Some("mysql")); + let mut conn = Conn::new(opts).unwrap(); + conn.query_drop("CREATE TEMPORARY TABLE TEST_TABLE ( name varchar(255) )") + .unwrap(); + conn.exec_drop("SELECT * FROM TEST_TABLE", ()).unwrap(); + conn.query_drop( + r" + INSERT INTO TEST_TABLE (name) VALUES ('one'); + INSERT INTO TEST_TABLE (name) VALUES ('two'); + INSERT INTO TEST_TABLE (name) VALUES ('three');", + ) + .unwrap(); + conn.exec_drop("SELECT * FROM TEST_TABLE", ()).unwrap(); + + let mut query_result = conn + .query_iter( + r" + SELECT * FROM TEST_TABLE; + INSERT INTO TEST_TABLE (name) VALUES ('one'); + DO 0;", + ) + .unwrap(); + + while let Some(result) = query_result.iter() { + result.affected_rows(); + } + } + + #[test] + fn should_handle_multi_result_set() { + let opts = OptsBuilder::from_opts(get_opts()) + .prefer_socket(false) + .db_name(Some("mysql")); + let mut conn = Conn::new(opts).unwrap(); + conn.query_drop("DROP PROCEDURE IF EXISTS multi").unwrap(); + conn.query_drop( + r#"CREATE PROCEDURE multi() BEGIN + SELECT 1 UNION ALL SELECT 2; + DO 1; + SELECT 3 UNION ALL SELECT 4; + DO 1; + DO 1; + SELECT REPEAT('A', 17000000); + SELECT REPEAT('A', 17000000); + END"#, + ) + .unwrap(); + { + let mut query_result = conn.query_iter("CALL multi()").unwrap(); + let result_set = query_result + .by_ref() + .map(|row| row.unwrap().unwrap().pop().unwrap()) + .collect::>(); + assert_eq!(result_set, vec![Bytes(b"1".to_vec()), Bytes(b"2".to_vec())]); + let result_set = query_result + .by_ref() + .map(|row| row.unwrap().unwrap().pop().unwrap()) + .collect::>(); + assert_eq!(result_set, vec![Bytes(b"3".to_vec()), Bytes(b"4".to_vec())]); + } + let mut result = conn.query_iter("SELECT 1; SELECT 2; SELECT 3;").unwrap(); + let mut i = 0; + while let Some(result_set) = result.iter() { + i += 1; + for row in result_set { + match i { + 1 => assert_eq!(row.unwrap().unwrap(), vec![Bytes(b"1".to_vec())]), + 2 => assert_eq!(row.unwrap().unwrap(), vec![Bytes(b"2".to_vec())]), + 3 => assert_eq!(row.unwrap().unwrap(), vec![Bytes(b"3".to_vec())]), + _ => unreachable!(), + } + } + } + assert_eq!(i, 3); + } + + #[test] + fn issue_273() { + let opts = OptsBuilder::from_opts(get_opts()).prefer_socket(false); + let mut conn = Conn::new(opts).unwrap(); + + "DROP FUNCTION IF EXISTS f1".run(&mut conn).unwrap(); + r"CREATE DEFINER=`root`@`localhost` FUNCTION `f1`(p_arg INT, p_arg2 INT) RETURNS int + DETERMINISTIC + BEGIN + RETURN p_arg + p_arg2; + END" + .run(&mut conn) + .unwrap(); + + "SELECT f1(?, ?)" + .with((100u8, 100u8)) + .run(&mut conn) + .unwrap(); + } + + #[test] + fn issue_285() { + let (tx, rx) = sync_channel::<()>(0); + + let handle = std::thread::spawn(move || { + let mut conn = Conn::new(get_opts()).unwrap(); + const INVALID_SQL: &str = r#" + CREATE TEMPORARY TABLE IF NOT EXISTS `user_details` ( + `user_id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(255) DEFAULT NULL, + `first_name` varchar(50) DEFAULT NULL, + `last_name` varchar(50) DEFAULT NULL, + PRIMARY KEY (`user_id`) + ); + + INSERT INTO `user_details` (`user_id`, `username`, `first_name`, `last_name`) + VALUES (1, 'rogers63', 'david') + "#; + + conn.query_iter(INVALID_SQL).unwrap(); + tx.send(()).unwrap(); + }); + + match rx.recv_timeout(Duration::from_secs(100_000)) { + Ok(_) => handle.join().unwrap(), + Err(_) => panic!("test failed"), + } + } + + #[test] + fn should_work_with_named_params() { + let mut conn = Conn::new(get_opts()).unwrap(); + { + let stmt = conn.prep("SELECT :a, :b, :a, :c").unwrap(); + let result = conn + .exec_first(&stmt, params! {"a" => 1, "b" => 2, "c" => 3}) + .unwrap() + .unwrap(); + assert_eq!((1_u8, 2_u8, 1_u8, 3_u8), result); + } + + let result = conn + .exec_first( + "SELECT :a, :b, :a + :b, :c", + params! { + "a" => 1, + "b" => 2, + "c" => 3, + }, + ) + .unwrap() + .unwrap(); + assert_eq!((1_u8, 2_u8, 3_u8, 3_u8), result); + } + + #[test] + fn should_return_error_on_missing_named_parameter() { + let mut conn = Conn::new(get_opts()).unwrap(); + let stmt = conn.prep("SELECT :a, :b, :a, :c, :d").unwrap(); + let result = + conn.exec_first::(&stmt, params! {"a" => 1, "b" => 2, "c" => 3,}); + match result { + Err(DriverError(crate::DriverError::Params(ParamsError::Missing( + MissingNamedParameterError(x), + )))) if x == b"d" => (), + Err(e) => panic!("MissingNamedParameter error expected, got {e}"), + Ok(_) => panic!("MissingNamedParameter error expected, got Ok"), + } + } + + #[test] + fn should_return_error_on_named_params_for_positional_statement() { + let mut conn = Conn::new(get_opts()).unwrap(); + let stmt = conn.prep("SELECT ?, ?, ?, ?, ?").unwrap(); + let result = conn.exec_drop(&stmt, params! {"a" => 1, "b" => 2, "c" => 3,}); + match result { + Err(DriverError(crate::DriverError::Params(ParamsError::Confusion( + ParamsConfusionError::NamedParamsForPositionalQuery, + )))) => (), + _ => panic!("NamedParamsForPositionalQuery error expected"), + } + } + + #[test] + fn should_handle_tcp_connect_timeout() { + use crate::error::{DriverError::ConnectTimeout, Error::DriverError}; + + let opts = OptsBuilder::from_opts(get_opts()) + .prefer_socket(false) + .tcp_connect_timeout(Some(::std::time::Duration::from_millis(1000))); + assert!(Conn::new(opts).unwrap().ping().is_ok()); + + let opts = OptsBuilder::from_opts(get_opts()) + .prefer_socket(false) + .tcp_connect_timeout(Some(::std::time::Duration::from_millis(1000))) + .ip_or_hostname(Some("192.168.255.255")); + match Conn::new(opts).unwrap_err() { + DriverError(ConnectTimeout) => {} + err => panic!("Unexpected error: {}", err), + } + } + + #[test] + fn should_set_additional_capabilities() { + use crate::consts::CapabilityFlags; + + let opts = OptsBuilder::from_opts(get_opts()) + .additional_capabilities(CapabilityFlags::CLIENT_FOUND_ROWS); + + let mut conn = Conn::new(opts).unwrap(); + conn.query_drop("CREATE TEMPORARY TABLE mysql.tbl (a INT, b TEXT)") + .unwrap(); + conn.query_drop("INSERT INTO mysql.tbl (a, b) VALUES (1, 'foo')") + .unwrap(); + let result = conn + .query_iter("UPDATE mysql.tbl SET b = 'foo' WHERE a = 1") + .unwrap(); + assert_eq!(result.affected_rows(), 1); + } + + #[test] + fn should_bind_before_connect() { + let port = 28000 + (rand::random::() % 2000); + let opts = OptsBuilder::from_opts(get_opts()) + .prefer_socket(false) + .ip_or_hostname(Some("localhost")) + .bind_address(Some(([127, 0, 0, 1], port))); + let conn = Conn::new(opts).unwrap(); + let debug_format: String = format!("{:?}", conn); + let expected_1 = format!("addr: V4(127.0.0.1:{})", port); + let expected_2 = format!("addr: 127.0.0.1:{}", port); + assert!( + debug_format.contains(&expected_1) || debug_format.contains(&expected_2), + "debug_format: {}", + debug_format + ); + } + + #[test] + fn should_bind_before_connect_with_timeout() { + let port = 30000 + (rand::random::() % 2000); + let opts = OptsBuilder::from_opts(get_opts()) + .prefer_socket(false) + .ip_or_hostname(Some("localhost")) + .bind_address(Some(([127, 0, 0, 1], port))) + .tcp_connect_timeout(Some(::std::time::Duration::from_millis(1000))); + let mut conn = Conn::new(opts).unwrap(); + assert!(conn.ping().is_ok()); + let debug_format: String = format!("{:?}", conn); + let expected_1 = format!("addr: V4(127.0.0.1:{})", port); + let expected_2 = format!("addr: 127.0.0.1:{}", port); + assert!( + debug_format.contains(&expected_1) || debug_format.contains(&expected_2), + "debug_format: {}", + debug_format + ); + } + + #[test] + fn should_not_cache_statements_if_stmt_cache_size_is_zero() { + let opts = OptsBuilder::from_opts(get_opts()).stmt_cache_size(0); + let mut conn = Conn::new(opts).unwrap(); + + let stmt1 = conn.prep("DO 1").unwrap(); + let stmt2 = conn.prep("DO 2").unwrap(); + let stmt3 = conn.prep("DO 3").unwrap(); + + conn.close(stmt1).unwrap(); + conn.close(stmt2).unwrap(); + conn.close(stmt3).unwrap(); + + let status: (Value, u8) = conn + .query_first("SHOW SESSION STATUS LIKE 'Com_stmt_close';") + .unwrap() + .unwrap(); + assert_eq!(status.1, 3); + } + + #[test] + fn should_hold_stmt_cache_size_bounds() { + let opts = OptsBuilder::from_opts(get_opts()).stmt_cache_size(3); + let mut conn = Conn::new(opts).unwrap(); + + conn.prep("DO 1").unwrap(); + conn.prep("DO 2").unwrap(); + conn.prep("DO 3").unwrap(); + conn.prep("DO 1").unwrap(); + conn.prep("DO 4").unwrap(); + conn.prep("DO 3").unwrap(); + conn.prep("DO 5").unwrap(); + conn.prep("DO 6").unwrap(); + + let status: (String, usize) = conn + .query_first("SHOW SESSION STATUS LIKE 'Com_stmt_close'") + .unwrap() + .unwrap(); + + assert_eq!(status.1, 3); + + let mut order = conn + .0 + .stmt_cache + .iter() + .map(|(_, entry)| &**entry.query.0.as_ref()) + .collect::>(); + order.sort(); + assert_eq!(order, &[b"DO 3", b"DO 5", b"DO 6"]); + } + + #[test] + fn should_handle_json_columns() { + use crate::{Deserialized, Serialized}; + use serde::{Deserialize, Serialize}; + use serde_json::Value as Json; + use std::str::FromStr; + + #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] + pub struct DecTest { + foo: String, + quux: (u64, String), + } + + let decodable = DecTest { + foo: "bar".into(), + quux: (42, "hello".into()), + }; + + let mut conn = Conn::new(get_opts()).unwrap(); + if conn + .query_drop("CREATE TEMPORARY TABLE mysql.tbl(a VARCHAR(32), b JSON)") + .is_err() + { + conn.query_drop("CREATE TEMPORARY TABLE mysql.tbl(a VARCHAR(32), b TEXT)") + .unwrap(); + } + conn.exec_drop( + r#"INSERT INTO mysql.tbl VALUES ('hello', ?)"#, + (Serialized(&decodable),), + ) + .unwrap(); + + let (a, b): (String, Json) = conn + .query_first("SELECT a, b FROM mysql.tbl") + .unwrap() + .unwrap(); + assert_eq!( + (a, b), + ( + "hello".into(), + Json::from_str(r#"{"foo": "bar", "quux": [42, "hello"]}"#).unwrap() + ) + ); + + let row = conn + .exec_first("SELECT a, b FROM mysql.tbl WHERE a = ?", ("hello",)) + .unwrap() + .unwrap(); + let (a, Deserialized(b)) = from_row(row); + assert_eq!((a, b), (String::from("hello"), decodable)); + } + + #[test] + fn should_set_connect_attrs() { + let opts = OptsBuilder::from_opts( + get_opts().connect_attrs::(Some(Default::default())), + ); + let mut conn = Conn::new(opts).unwrap(); + + let support_connect_attrs = match (conn.0.server_version, conn.0.mariadb_server_version) + { + (Some(ref version), _) if *version >= (5, 6, 0) => true, + (_, Some(ref version)) if *version >= (10, 0, 0) => true, + _ => false, + }; + + if support_connect_attrs { + // MySQL >= 5.6 or MariaDB >= 10.0 + + if get_system_variable::(&mut conn, "performance_schema") != "ON" { + panic!("The system variable `performance_schema` is off. Restart the MySQL server with `--performance_schema=on` to pass the test."); + } + let attrs_size: i32 = + get_system_variable(&mut conn, "performance_schema_session_connect_attrs_size"); + if (0..=128).contains(&attrs_size) { + panic!("The system variable `performance_schema_session_connect_attrs_size` is {}. Restart the MySQL server with `--performance_schema_session_connect_attrs_size=-1` to pass the test.", attrs_size); + } + + fn assert_connect_attrs(conn: &mut Conn, expected_values: &[(&str, &str)]) { + let mut actual_values = HashMap::new(); + for row in conn.query_iter("SELECT attr_name, attr_value FROM performance_schema.session_account_connect_attrs WHERE processlist_id = connection_id()").unwrap() { + let (name, value) = from_row::<(String, String)>(row.unwrap()); + actual_values.insert(name, value); + } + + for (name, value) in expected_values { + assert_eq!(actual_values.get(*name), Some(&value.to_string())); + } + } + + let pid = process::id().to_string(); + let prog_name = std::env::args_os() + .next() + .unwrap() + .to_string_lossy() + .into_owned(); + let mut expected_values = vec![ + ("_client_name", "rust-mysql-simple"), + ("_client_version", env!("CARGO_PKG_VERSION")), + ("_os", env!("CARGO_CFG_TARGET_OS")), + ("_pid", &pid), + ("_platform", env!("CARGO_CFG_TARGET_ARCH")), + ("program_name", &prog_name), + ]; + + // No connect attributes are added. + assert_connect_attrs(&mut conn, &expected_values); + + // Connect attributes are added. + let opts = OptsBuilder::from_opts(get_opts()); + let mut connect_attrs = HashMap::with_capacity(3); + connect_attrs.insert("foo", "foo val"); + connect_attrs.insert("bar", "bar val"); + connect_attrs.insert("program_name", "my program name"); + let mut conn = Conn::new(opts.connect_attrs(Some(connect_attrs))).unwrap(); + expected_values.pop(); // remove program_name at the last + expected_values.push(("foo", "foo val")); + expected_values.push(("bar", "bar val")); + expected_values.push(("program_name", "my program name")); + assert_connect_attrs(&mut conn, &expected_values); + } + } + + // This test verifies that the metadata is correct with or without metadata caching, and that protocol + // is not broken afterwards and data is read correctly. It doesn't test that the metadata is really cached + // (if that is possible) and not received twice. + #[test] + fn test_metadata_caching() { + use crate::consts::ColumnType; + let mut conn = Conn::new(get_opts()).unwrap(); + if conn.0.mariadb_server_version.is_none() { + return; + } + + conn.query_drop( + r"CREATE TEMPORARY TABLE t_metadata_caching ( + id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, + val VARCHAR(32) NOT NULL)", + ) + .unwrap(); + + // Populating table with some data to verify that data still fetched correctly with cached metadata use + let insert_stmt = conn + .prep("INSERT INTO t_metadata_caching (val) VALUES (?)") + .unwrap(); + let _ = conn.exec_drop(&insert_stmt, ("AAA",)); + let _ = conn.exec_drop(&insert_stmt, ("BB",)); + let mut ps = conn.prep("SELECT id, val FROM t_metadata_caching").unwrap(); + + let mut columns_from_prep = ps.columns(); + let mut metadata_from_prep: Vec<(String, ColumnType)> = columns_from_prep + .iter() + .map(|column| (column.name_str().to_string(), column.column_type())) + .collect(); + + let mut query_result = conn.exec_iter(&ps, ()).unwrap(); + let mut columns_from_exec1 = query_result.columns(); + let mut metadata_from_exec1: Vec<(String, ColumnType)> = columns_from_exec1 + .as_ref() + .iter() + .map(|column| (column.name_str().to_string(), column.column_type())) + .collect(); + + // Comparing and verifying metadata. + assert_eq!(metadata_from_prep, metadata_from_exec1); + assert_eq!(metadata_from_prep.len(), 2); + assert_eq!(metadata_from_prep[0].0, "id"); + assert_eq!(metadata_from_prep[0].1, ColumnType::MYSQL_TYPE_LONG); + assert_eq!(metadata_from_prep[1].0, "val"); + assert_eq!(metadata_from_prep[1].1, ColumnType::MYSQL_TYPE_VAR_STRING); + + let fetched_rows: Vec<(i32, String)> = query_result + .map(|row_result| crate::from_row(row_result.unwrap())) + .collect(); + + let expected_rows = [(1, "AAA".to_string()), (2, "BB".to_string())]; + assert_eq!(fetched_rows.len(), expected_rows.len()); + + for (fetched, expected) in fetched_rows.iter().zip(expected_rows.iter()) { + assert_eq!(fetched, expected); + } + + // Doing the same for exec_first. Technically it's internally the same as exec_iter, + // but the test isn't supposed to know that and to test it + ps = conn + .prep("SELECT val FROM t_metadata_caching WHERE id = ?") + .unwrap(); + columns_from_prep = ps.columns(); + metadata_from_prep = columns_from_prep + .iter() + .map(|column| (column.name_str().to_string(), column.column_type())) + .collect(); + let single_row: Option = conn.exec_first(&ps, (1,)).unwrap(); + if let Some(val) = single_row { + assert_eq!(metadata_from_prep.len(), 1); + assert_eq!(metadata_from_prep[0].0, "val"); + assert_eq!(metadata_from_prep[0].1, ColumnType::MYSQL_TYPE_VAR_STRING); + + assert_eq!(val, "AAA".to_string()); + } + // Testing the case when metadata is changed after execution + ps = conn.prep("SELECT ?").unwrap(); + + columns_from_prep = ps.columns(); + metadata_from_prep = columns_from_prep + .iter() + .map(|column| (column.name_str().to_string(), column.column_type())) + .collect(); + + // First query — server sends metadata because the type has changed + query_result = conn.exec_iter(&ps, (12,)).unwrap(); + columns_from_exec1 = query_result.columns(); + metadata_from_exec1 = columns_from_exec1 + .as_ref() + .iter() + .map(|column| (column.name_str().to_string(), column.column_type())) + .collect(); + let fetched_rows: Vec = query_result + .map(|row_result| crate::from_row(row_result.unwrap())) + .collect(); + + // Second query — server skips metadata packets + query_result = conn.exec_iter(&ps, (42,)).unwrap(); + let columns_from_exec2 = query_result.columns(); + let metadata_from_exec2 = columns_from_exec2 + .as_ref() + .iter() + .map(|column| (column.name_str().to_string(), column.column_type())) + .collect::>(); + let fetched_rows2: Vec = query_result + .map(|row_result| crate::from_row(row_result.unwrap())) + .collect(); + + // Third query — server sends metadata because the type has changed + query_result = conn.exec_iter(&ps, ("foo",)).unwrap(); + let columns_from_exec3 = query_result.columns(); + let metadata_from_exec3 = columns_from_exec3 + .as_ref() + .iter() + .map(|column| (column.name_str().to_string(), column.column_type())) + .collect::>(); + let fetched_rows3: Vec = query_result + .map(|row_result| crate::from_row(row_result.unwrap())) + .collect(); + + // Comparing and verifying metadata. + assert_eq!(metadata_from_exec1.len(), 1); + assert_eq!(metadata_from_exec2.len(), 1); + assert_eq!(metadata_from_exec3.len(), 1); + assert_eq!(metadata_from_prep.len(), 1); + assert_eq!(metadata_from_prep[0].0, "?"); + assert!( + metadata_from_prep[0].1 == ColumnType::MYSQL_TYPE_NULL + || metadata_from_prep[0].1 == ColumnType::MYSQL_TYPE_VAR_STRING, + "Expected MYSQL_TYPE_NULL(MariaDB) or MYSQL_TYPE_VAR_STRING(MySQL), got {:?}", + metadata_from_prep[0].1 + ); + assert_eq!(metadata_from_exec1[0].0, "?"); + assert_eq!(metadata_from_exec1[0].1, ColumnType::MYSQL_TYPE_LONGLONG); + assert_eq!(metadata_from_exec2[0].0, "?"); + assert_eq!(metadata_from_exec2[0].1, ColumnType::MYSQL_TYPE_LONGLONG); + assert_eq!(metadata_from_exec3[0].0, "?"); + assert_eq!(metadata_from_exec3[0].1, ColumnType::MYSQL_TYPE_VAR_STRING); + + assert_eq!(fetched_rows[0], 12); + assert_eq!(fetched_rows2[0], 42); + assert_eq!(fetched_rows3[0], "foo".to_owned()); + } + + // Test for exec_batch method. + #[test] + fn test_exec_batch() { + let mut conn = Conn::new(get_opts()).unwrap(); + + conn.query_drop( + "CREATE TEMPORARY TABLE t_exec_batch (\ + id INT NOT NULL PRIMARY KEY,\ + val VARCHAR(32),\ + num BIGINT UNSIGNED)", + ) + .unwrap(); + + // Populating table with some data to verify that data still fetched correctly with cached metadata use + let insert_stmt = "INSERT INTO t_exec_batch (id,val,num) VALUES (?,?,?)"; + + let params = [ + (1, Some("First"), None), + (3, None, Some(1)), + (4, Some("Third"), Some(u64::MAX)), + ]; + + conn.exec_batch(insert_stmt, params.iter().copied()) + .unwrap(); + + conn.exec_batch(insert_stmt, [(8, None::, None::)]) + .unwrap(); + + let fetched_rows: Vec<(i32, Option, Option)> = conn + .query_iter("SELECT id, val, num FROM t_exec_batch") + .unwrap() + .map(|row_result| crate::from_row(row_result.unwrap())) + .collect(); + let expected_rows: Vec<(i32, Option, Option)> = vec![ + (1, Some("First".to_string()), None), + (3, None, Some(1)), + (4, Some("Third".to_string()), Some(u64::MAX)), + (8, None, None), + ]; + assert_eq!(fetched_rows, expected_rows); + + if conn.has_mariadb_capability(MariadbCapabilities::MARIADB_CLIENT_STMT_BULK_OPERATIONS) + { + let select_stmt = "SELECT ?"; + let err = conn + .exec_batch(select_stmt, [(1_u64,), (2_u64,), (3_u64,)]) + .unwrap_err(); + assert!(matches!(err, crate::Error::MySqlError(e) if e.code == 1295)); + } + } + + #[test] + fn test_exec_batch_large() { + const CLIENT_MAX_PACKET_SIZE: usize = 1024; // 1K + let opts = get_opts().max_allowed_packet(Some(CLIENT_MAX_PACKET_SIZE)); + let mut conn = Conn::new(opts).unwrap(); + conn.query_drop( + "CREATE TEMPORARY TABLE t_large_batch (id BIGINT NOT NULL PRIMARY KEY, + val VARCHAR(1024) NOT NULL)", + ) + .unwrap(); + + // Calculate a row size that will make the total packet size > max_allowed_packet + // Packet will have 4 byte header and 7 bytes COM_STMT_BULK_EXECUTE fields + 4 bytes for parameter types + // 8 bytes per row for id + 2 bytes for indicators + 3 bytes for length encoding of val. + let num_rows = 3; + let row_chunk_size = CLIENT_MAX_PACKET_SIZE / num_rows; + let mut row_data_1 = "a".repeat(row_chunk_size); + let row_data_2 = "b".repeat(row_chunk_size); + let row_data_3 = "c".repeat(row_chunk_size); + + let evaluated_packet_len = 4 + 7 + 4 + (1 + 8 + 1 + 3 + row_chunk_size * num_rows); + + assert!( + evaluated_packet_len > CLIENT_MAX_PACKET_SIZE, + "Data size must be greater than max packet size" + ); + + let params: Vec<(u64, &str)> = vec![ + (1, &row_data_1[..]), + (7, &row_data_2[..]), + (22, &row_data_3[..]), + ]; + + let query = "INSERT INTO t_large_batch (id, val) VALUES (?,?)"; + conn.exec_batch(query, params) + .expect("Batch execution should succeed"); + + let inserted_rows: Vec<(u64, String)> = conn + .query("SELECT id, val FROM t_large_batch ORDER BY id") // Order by data to get a predictable "a", "b", "c" order + .unwrap(); + + assert_eq!( + inserted_rows.len(), + num_rows, + "The number of inserted rows ({}) does not match the expected number ({})", + inserted_rows.len(), + num_rows + ); + + assert_eq!(inserted_rows[0], (1, row_data_1)); + assert_eq!(inserted_rows[1], (7, row_data_2)); + assert_eq!(inserted_rows[2], (22, row_data_3)); + + // Some corner cases: single row exceeding max_allowed_packet and + // empty batch + row_data_1 = "x".repeat(CLIENT_MAX_PACKET_SIZE); + let params: Vec<(u64, &str)> = vec![(33, &row_data_1[..])]; + let result = conn.exec_batch(query, params); + assert!( + result.is_err(), + "Batch execution should fail due to packet size exceeding max_allowed_packet" + ); + } + + #[test] + fn test_exec_batch_no_params() -> crate::Result<()> { + let mut conn = Conn::new(get_opts()).unwrap(); + conn.query_drop("CREATE TEMPORARY TABLE t_counter (counter INTEGER NOT NULL)") + .unwrap(); + conn.query_drop("INSERT INTO t_counter (counter) VALUES (0)") + .unwrap(); + + const COUNT: usize = 10; + conn.exec_batch("UPDATE t_counter SET counter = counter+1", vec![(); COUNT]) + .expect("Batch execution should succeed"); + let rows: Vec<(usize,)> = conn.query("SELECT counter FROM t_counter").unwrap(); + assert_eq!(rows, vec![(COUNT,)]); + Ok(()) + } + + #[cfg_attr(docsrs, doc(cfg(feature = "client_parsec")))] + #[test] + #[cfg(feature = "client_parsec")] + fn parsec_connect() { + let mut conn = Conn::new(get_opts()).unwrap(); + let is_mariadb = conn.0.mariadb_server_version.is_some(); + let version = conn.server_version(); + if is_mariadb && version >= (11, 6, 0) { + // Creating random password so in case of test failure user won't have + // known password left behind. + let mut rng = rand::rng(); + let mut pass_bytes = [0u8; 16]; + rng.fill_bytes(&mut pass_bytes); + pass_bytes.iter_mut().for_each(|b| { + *b = match *b % 3 { + 0 => b'A' + (*b % 26), + 1 => b'a' + (*b % 26), + _ => b'0' + (*b % 10), + } + }); + let pass = String::from_utf8_lossy(&pass_bytes).to_string(); + + conn.query_drop("DROP USER IF EXISTS 'parsec_test_user'@'%'") + .unwrap(); + let create_user_query = format!( + "CREATE USER 'parsec_test_user'@'%' IDENTIFIED VIA 'parsec' USING PASSWORD('{}')", + pass + ); + conn.query_drop(create_user_query).unwrap(); + let mut conn_parsec = Conn::new( + get_opts() + .user(Some("parsec_test_user")) + .pass(Some(pass)) + .db_name(None::) + .init(vec![] as Vec), + ) + .unwrap(); + assert!(conn_parsec.ping().is_ok()); + conn.query_drop("DROP USER 'parsec_test_user'@'%'").unwrap(); + } + } + + #[test] + #[cfg(feature = "binlog")] + fn should_read_binlog() -> crate::Result<()> { + use std::{ + collections::HashMap, sync::mpsc::sync_channel, thread::spawn, time::Duration, + }; + + fn gen_dummy_data() -> crate::Result<()> { + let mut conn = Conn::new(get_opts())?; + + "CREATE TABLE IF NOT EXISTS customers (customer_id int not null)".run(&mut conn)?; + + for i in 0_u8..100 { + "INSERT INTO customers(customer_id) VALUES (?)" + .with((i,)) + .run(&mut conn)?; + } + + "DROP TABLE customers".run(&mut conn)?; + + Ok(()) + } + + fn get_conn() -> crate::Result<(Conn, Vec, u64)> { + let mut conn = Conn::new(get_opts())?; + + if let Ok(Some(gtid_mode)) = + "SELECT @@GLOBAL.GTID_MODE".first::(&mut conn) + { + if !gtid_mode.starts_with("ON") { + panic!( + "GTID_MODE is disabled \ + (enable using --gtid_mode=ON --enforce_gtid_consistency=ON)" + ); + } + } + + let row: crate::Row = "SHOW BINARY LOGS".first(&mut conn)?.unwrap(); + let filename = row.get(0).unwrap(); + let position = row.get(1).unwrap(); + + gen_dummy_data().unwrap(); + Ok((conn, filename, position)) + } + + // iterate using COM_BINLOG_DUMP + let (conn, filename, pos) = get_conn().unwrap(); + let is_mariadb = conn.0.mariadb_server_version.is_some(); + + let binlog_stream = conn + .get_binlog_stream(BinlogRequest::new(12).with_filename(filename).with_pos(pos)) + .unwrap(); + + let mut events_num = 0; + let (tx, rx) = sync_channel(0); + spawn(move || { + for event in binlog_stream { + tx.send(event).unwrap(); + } + }); + let mut tmes = HashMap::new(); + while let Ok(event) = rx.recv_timeout(Duration::from_secs(1)) { + let event = event.unwrap(); + events_num += 1; + + // assert that event type is known + event.header().event_type().unwrap(); + + // iterate over rows of an event + match event.read_data()?.unwrap() { + EventData::TableMapEvent(tme) => { + tmes.insert(tme.table_id(), tme.into_owned()); + } + EventData::RowsEvent(re) => { + for row in re.rows(&tmes[&re.table_id()]) { + row.unwrap(); + } + } + _ => (), + } + } + assert!(events_num > 0); + + if !is_mariadb { + // iterate using COM_BINLOG_DUMP_GTID + let (conn, filename, pos) = get_conn().unwrap(); + + let binlog_stream = conn + .get_binlog_stream( + BinlogRequest::new(13) + .with_use_gtid(true) + .with_filename(filename) + .with_pos(pos), + ) + .unwrap(); + + let mut events_num = 0; + let (tx, rx) = sync_channel(0); + spawn(move || { + for event in binlog_stream { + tx.send(event).unwrap(); + } + }); + let mut tmes = HashMap::new(); + while let Ok(event) = rx.recv_timeout(Duration::from_secs(1)) { + let event = event.unwrap(); + events_num += 1; + + // assert that event type is known + event.header().event_type().unwrap(); + + // iterate over rows of an event + match event.read_data()?.unwrap() { + EventData::TableMapEvent(tme) => { + tmes.insert(tme.table_id(), tme.into_owned()); + } + EventData::RowsEvent(re) => { + for row in re.rows(&tmes[&re.table_id()]) { + row.unwrap(); + } + } + _ => (), + } + } + assert!(events_num > 0); + } + + // iterate using COM_BINLOG_DUMP with BINLOG_DUMP_NON_BLOCK flag + let (conn, filename, pos) = get_conn().unwrap(); + + let binlog_stream = conn + .get_binlog_stream( + BinlogRequest::new(14) + .with_filename(filename) + .with_pos(pos) + .with_flags(crate::BinlogDumpFlags::BINLOG_DUMP_NON_BLOCK), + ) + .unwrap(); + + events_num = 0; + for event in binlog_stream { + let event = event.unwrap(); + events_num += 1; + event.header().event_type().unwrap(); + event.read_data()?; + } + assert!(events_num > 0); + + Ok(()) + } + } + + #[cfg(feature = "nightly")] + mod bench { + use test; + + use crate::{params, prelude::*, test_misc::get_opts, Conn, Value::NULL}; + + #[bench] + fn simple_exec(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + bencher.iter(|| { + let _ = conn.query_drop("DO 1"); + }) + } + + #[bench] + fn prepared_exec(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + let stmt = conn.prep("DO 1").unwrap(); + bencher.iter(|| { + let _ = conn.exec_drop(&stmt, ()).unwrap(); + }) + } + + #[bench] + fn prepare_and_exec(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + bencher.iter(|| { + let stmt = conn.prep("SELECT ?").unwrap(); + let _ = conn.exec_drop(&stmt, (0,)).unwrap(); + }) + } + + #[bench] + fn simple_query_row(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + bencher.iter(|| { + let _ = conn.query_drop("SELECT 1").unwrap(); + }) + } + + #[bench] + fn simple_prepared_query_row(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + let stmt = conn.prep("SELECT 1").unwrap(); + bencher.iter(|| { + let _ = conn.exec_drop(&stmt, ()).unwrap(); + }) + } + + #[bench] + fn simple_prepared_query_row_with_param(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + let stmt = conn.prep("SELECT ?").unwrap(); + bencher.iter(|| { + let _ = conn.exec_drop(&stmt, (0,)).unwrap(); + }) + } + + #[bench] + fn simple_prepared_query_row_with_named_param(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + let stmt = conn.prep("SELECT :a").unwrap(); + bencher.iter(|| { + let _ = conn.exec_drop(&stmt, params! {"a" => 0}).unwrap(); + }) + } + + #[bench] + fn simple_prepared_query_row_with_5_params(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + let stmt = conn.prep("SELECT ?, ?, ?, ?, ?").unwrap(); + let params = (42i8, b"123456".to_vec(), 1.618f64, NULL, 1i8); + bencher.iter(|| { + let _ = conn.exec_drop(&stmt, ¶ms).unwrap(); + }) + } + + #[bench] + fn simple_prepared_query_row_with_5_named_params(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + let stmt = conn + .prep("SELECT :one, :two, :three, :four, :five") + .unwrap(); + bencher.iter(|| { + let _ = conn.exec_drop( + &stmt, + params! { + "one" => 42i8, + "two" => b"123456", + "three" => 1.618f64, + "four" => NULL, + "five" => 1i8, + }, + ); + }) + } + + #[bench] + fn select_large_string(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + bencher.iter(|| { + let _ = conn.query_drop("SELECT REPEAT('A', 10000)").unwrap(); + }) + } + + #[bench] + fn select_prepared_large_string(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + let stmt = conn.prep("SELECT REPEAT('A', 10000)").unwrap(); + bencher.iter(|| { + let _ = conn.exec_drop(&stmt, ()).unwrap(); + }) + } + + #[bench] + fn many_small_rows(bencher: &mut test::Bencher) { + let mut conn = Conn::new(get_opts()).unwrap(); + conn.query_drop("CREATE TEMPORARY TABLE mysql.x (id INT)") + .unwrap(); + for _ in 0..512 { + conn.query_drop("INSERT INTO mysql.x VALUES (256)").unwrap(); + } + let stmt = conn.prep("SELECT * FROM mysql.x").unwrap(); + bencher.iter(|| { + let _ = conn.exec_drop(&stmt, ()).unwrap(); + }); + } + } +} diff --git a/vendor/mysql-28.0.0/src/conn/opts/mod.rs b/vendor/mysql-28.0.0/src/conn/opts/mod.rs new file mode 100644 index 0000000000..f44f9acf19 --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/opts/mod.rs @@ -0,0 +1,1554 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use percent_encoding::percent_decode; +use url::Url; + +use std::{ + borrow::Cow, collections::HashMap, fmt, hash::Hash, net::SocketAddr, path::Path, time::Duration, +}; + +use crate::{ + consts::CapabilityFlags, Compression, LocalInfileHandler, PoolConstraints, PoolOpts, UrlError, +}; + +/// Default value for client side per-connection statement cache. +pub const DEFAULT_STMT_CACHE_SIZE: usize = 32; + +mod native_tls_opts; +mod rustls_opts; + +pub mod pool_opts; + +#[cfg(feature = "native-tls")] +pub use native_tls_opts::ClientIdentity; + +#[cfg(feature = "rustls")] +pub use rustls_opts::ClientIdentity; + +/// Ssl Options. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Default)] +pub struct SslOpts { + #[cfg(any(feature = "native-tls", feature = "rustls"))] + client_identity: Option, + root_cert_path: Option>, + skip_domain_validation: bool, + accept_invalid_certs: bool, + cipher_suites: Option>, +} + +impl SslOpts { + /// Restricts rustls to the named IANA/OpenSSL-compatible cipher suites. + pub fn with_cipher_suites(mut self, suites: Option>) -> Self { + self.cipher_suites = suites; + self + } + + /// Returns the requested TLS cipher-suite names. + pub fn cipher_suites(&self) -> Option<&[String]> { + self.cipher_suites.as_deref() + } + + /// Sets the client identity. + #[cfg(any(feature = "native-tls", feature = "rustls"))] + #[cfg_attr( + docsrs, + doc(cfg(any( + feature = "native-tls", + feature = "rustls-tls", + feature = "rustls-tls-ring" + ))) + )] + pub fn with_client_identity(mut self, identity: Option) -> Self { + self.client_identity = identity; + self + } + + /// Sets path to a certificate of the root that connector will trust. + /// + /// Supported certificate formats are .der and .pem. + /// Multiple certs are allowed in .pem files. + pub fn with_root_cert_path>>( + mut self, + root_cert_path: Option, + ) -> Self { + self.root_cert_path = root_cert_path.map(Into::into); + self + } + + /// The way to not validate the server's domain + /// name against its certificate (defaults to `false`). + pub fn with_danger_skip_domain_validation(mut self, value: bool) -> Self { + self.skip_domain_validation = value; + self + } + + /// If `true` then client will accept invalid certificate (expired, not trusted, ..) + /// (defaults to `false`). + pub fn with_danger_accept_invalid_certs(mut self, value: bool) -> Self { + self.accept_invalid_certs = value; + self + } + + #[cfg(any(feature = "native-tls", feature = "rustls"))] + #[cfg_attr( + docsrs, + doc(cfg(any( + feature = "native-tls", + feature = "rustls-tls", + feature = "rustls-tls-ring" + ))) + )] + pub fn client_identity(&self) -> Option<&ClientIdentity> { + self.client_identity.as_ref() + } + + pub fn root_cert_path(&self) -> Option<&Path> { + self.root_cert_path.as_ref().map(AsRef::as_ref) + } + + pub fn skip_domain_validation(&self) -> bool { + self.skip_domain_validation + } + + pub fn accept_invalid_certs(&self) -> bool { + self.accept_invalid_certs + } +} + +/// Options structure is quite large so we'll store it separately. +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) struct InnerOpts { + /// Address of mysql server (defaults to `127.0.0.1`). Host names should also work. + ip_or_hostname: url::Host, + /// TCP port of mysql server (defaults to `3306`). + tcp_port: u16, + /// Path to unix socket on unix or pipe name on windows (defaults to `None`). + /// + /// Can be defined using `socket` connection url parameter. + socket: Option, + /// User (defaults to `None`). + user: Option, + /// Password (defaults to `None`). + pass: Option, + /// Database name (defaults to `None`). + db_name: Option, + + /// The timeout for each attempt to read from the server. + read_timeout: Option, + + /// The timeout for each attempt to write to the server. + write_timeout: Option, + + /// Prefer socket connection (defaults to `true`). + /// + /// Will reconnect via socket (or named pipe on windows) after TCP + /// connection to `127.0.0.1` if `true`. + /// + /// Will fall back to TCP on error. Use `socket` option to enforce socket connection. + /// + /// Can be defined using `prefer_socket` connection url parameter. + prefer_socket: bool, + + /// Whether to enable `TCP_NODELAY` (defaults to `true`). + /// + /// This option disables Nagle's algorithm, which can cause unusually high latency (~40ms) at + /// some cost to maximum throughput. See #132. + tcp_nodelay: bool, + + /// TCP keep alive time for mysql connection. + /// + /// Can be defined using `tcp_keepalive_time_ms` connection url parameter. + tcp_keepalive_time: Option, + + /// TCP keep alive interval between subsequent probe for mysql connection. + /// + /// Can be defined using `tcp_keepalive_probe_interval_secs` connection url parameter. + #[cfg(any(target_os = "linux", target_os = "macos"))] + tcp_keepalive_probe_interval_secs: Option, + + /// TCP keep alive probe count for mysql connection. + /// + /// Can be defined using `tcp_keepalive_probe_count` connection url parameter. + #[cfg(any(target_os = "linux", target_os = "macos"))] + tcp_keepalive_probe_count: Option, + + /// TCP_USER_TIMEOUT time for mysql connection. + /// + /// Can be defined using `tcp_user_timeout_ms` connection url parameter. + #[cfg(target_os = "linux")] + tcp_user_timeout: Option, + + /// Commands to execute on each new database connection. + init: Vec, + + /// Driver will require SSL connection if this option isn't `None` (default to `None`). + ssl_opts: Option, + + /// Trusted server RSA key for non-TLS caching_sha2_password authentication. + server_public_key_path: Option>, + + /// Connection pool options (defaults to [`PoolOpts::default`]). + pool_opts: PoolOpts, + + /// Callback to handle requests for local files. + /// + /// These are caused by using `LOAD DATA LOCAL INFILE` queries. + /// The callback is passed the filename, and a `Write`able object + /// to receive the contents of that file. + /// + /// If unset, the default callback will read files relative to + /// the current directory. + local_infile_handler: Option, + + /// Tcp connect timeout (defaults to `None`). + /// + /// Can be defined using `tcp_connect_timeout_ms` connection url parameter. + tcp_connect_timeout: Option, + + /// Bind address for a client (defaults to `None`). + /// + /// Use carefully. Will probably make pool unusable because of *address already in use* + /// errors. + bind_address: Option, + + /// Number of prepared statements cached on the client side (per connection). + /// Defaults to [`DEFAULT_STMT_CACHE_SIZE`]. + /// + /// Can be defined using `stmt_cache_size` connection url parameter. + stmt_cache_size: usize, + + /// If not `None`, then client will ask for compression if server supports it + /// (defaults to `None`). + /// + /// Can be defined using `compress` connection url parameter with values `true`, `fast`, `best`, + /// `0`, `1`, ..., `9`. + /// + /// Note that compression level defined here will affect only outgoing packets. + compress: Option, + + /// Additional client capabilities to set (defaults to empty). + /// + /// This value will be OR'ed with other client capabilities during connection initialization. + /// + /// ### Note + /// + /// It is a good way to set something like `CLIENT_FOUND_ROWS` but you should note that it + /// won't let you to interfere with capabilities managed by other options (like + /// `CLIENT_SSL` or `CLIENT_COMPRESS`). Also note that some capabilities are reserved, + /// pointless or may broke the connection, so this option should be used with caution. + additional_capabilities: CapabilityFlags, + + /// Connect attributes + connect_attrs: Option>, + + /// Disables `mysql_old_password` plugin (defaults to `true`). + /// + /// Available via `secure_auth` connection url parameter. + secure_auth: bool, + + /// Enables Client-Side Cleartext Pluggable Authentication (defaults to `false`). + /// + /// Enables client to send passwords to the server as cleartext, without hashing or encryption + /// (consult MySql documentation for more info). + /// + /// # Security Notes + /// + /// Sending passwords as cleartext may be a security problem in some configurations. Please + /// consider using TLS or encrypted tunnels for server connection. + enable_cleartext_plugin: bool, + + /// Client side `max_allowed_packet` value (defaults to `None`). + /// + /// By default `Conn` will query this value from the server. One can avoid this step + /// by explicitly specifying it. + max_allowed_packet: Option, + + /// For tests only + #[cfg(test)] + pub injected_socket: Option, +} + +impl Default for InnerOpts { + fn default() -> Self { + InnerOpts { + ip_or_hostname: url::Host::Domain(String::from("localhost")), + tcp_port: 3306, + socket: None, + max_allowed_packet: None, + user: None, + pass: None, + db_name: None, + read_timeout: None, + write_timeout: None, + prefer_socket: true, + init: vec![], + ssl_opts: None, + server_public_key_path: None, + pool_opts: PoolOpts::default(), + tcp_keepalive_time: None, + #[cfg(any(target_os = "linux", target_os = "macos",))] + tcp_keepalive_probe_interval_secs: None, + #[cfg(any(target_os = "linux", target_os = "macos",))] + tcp_keepalive_probe_count: None, + #[cfg(target_os = "linux")] + tcp_user_timeout: None, + tcp_nodelay: true, + local_infile_handler: None, + tcp_connect_timeout: None, + bind_address: None, + stmt_cache_size: DEFAULT_STMT_CACHE_SIZE, + compress: None, + additional_capabilities: CapabilityFlags::empty(), + connect_attrs: Some(HashMap::new()), + secure_auth: true, + enable_cleartext_plugin: false, + #[cfg(test)] + injected_socket: None, + } + } +} + +impl TryFrom<&'_ str> for Opts { + type Error = UrlError; + + fn try_from(url: &'_ str) -> Result { + Opts::from_url(url) + } +} + +/// Mysql connection options. +/// +/// Build one with [`OptsBuilder`](struct.OptsBuilder.html). +#[derive(Clone, Eq, PartialEq, Debug, Default)] +pub struct Opts(pub(crate) Box); + +impl Opts { + #[doc(hidden)] + pub fn addr_is_loopback(&self) -> bool { + match self.0.ip_or_hostname { + url::Host::Domain(ref name) => name == "localhost", + url::Host::Ipv4(ref addr) => addr.is_loopback(), + url::Host::Ipv6(ref addr) => addr.is_loopback(), + } + } + + pub fn from_url(url: &str) -> Result { + from_url(url) + } + + pub(crate) fn get_host(&self) -> url::Host { + self.0.ip_or_hostname.clone() + } + + /// Address of mysql server (defaults to `127.0.0.1`). Host names should also work. + pub fn get_ip_or_hostname(&self) -> Cow<'_, str> { + self.0.ip_or_hostname.to_string().into() + } + /// TCP port of mysql server (defaults to `3306`). + pub fn get_tcp_port(&self) -> u16 { + self.0.tcp_port + } + /// Socket path on unix or pipe name on windows (defaults to `None`). + pub fn get_socket(&self) -> Option<&str> { + self.0.socket.as_deref() + } + /// Client side `max_allowed_packet` value (defaults to `None`). + /// + /// By default `Conn` will query this value from the server. One can avoid this step + /// by explicitly specifying it. Server side default is 4MB. + /// + /// Available in connection URL via `max_allowed_packet` parameter. + pub fn get_max_allowed_packet(&self) -> Option { + self.0.max_allowed_packet + } + /// User (defaults to `None`). + pub fn get_user(&self) -> Option<&str> { + self.0.user.as_deref() + } + /// Password (defaults to `None`). + pub fn get_pass(&self) -> Option<&str> { + self.0.pass.as_deref() + } + /// Database name (defaults to `None`). + pub fn get_db_name(&self) -> Option<&str> { + self.0.db_name.as_deref() + } + + /// The timeout for each attempt to write to the server. + pub fn get_read_timeout(&self) -> Option<&Duration> { + self.0.read_timeout.as_ref() + } + + /// The timeout for each attempt to write to the server. + pub fn get_write_timeout(&self) -> Option<&Duration> { + self.0.write_timeout.as_ref() + } + + /// Prefer socket connection (defaults to `true`). + /// + /// Will reconnect via socket (or named pipe on windows) after TCP connection + /// to `127.0.0.1` if `true`. + /// + /// Will fall back to TCP on error. Use `socket` option to enforce socket connection. + pub fn get_prefer_socket(&self) -> bool { + self.0.prefer_socket + } + // XXX: Wait for keepalive_timeout stabilization + /// Commands to execute on each new database connection. + pub fn get_init(&self) -> Vec { + self.0.init.clone() + } + + /// Driver will require SSL connection if this option isn't `None` (default to `None`). + pub fn get_ssl_opts(&self) -> Option<&SslOpts> { + self.0.ssl_opts.as_ref() + } + + /// Returns the caller-supplied authentication RSA public-key path. + pub fn get_server_public_key_path(&self) -> Option<&Path> { + self.0.server_public_key_path.as_deref() + } + + /// Connection pool options (defaults to [`Default::default`]). + pub fn get_pool_opts(&self) -> &PoolOpts { + &self.0.pool_opts + } + + /// Whether `TCP_NODELAY` will be set for mysql connection. + pub fn get_tcp_nodelay(&self) -> bool { + self.0.tcp_nodelay + } + + /// TCP keep alive time for mysql connection. + pub fn get_tcp_keepalive_time_ms(&self) -> Option { + self.0.tcp_keepalive_time + } + + /// TCP keep alive interval between subsequent probes for mysql connection. + #[cfg(any(target_os = "linux", target_os = "macos",))] + pub fn get_tcp_keepalive_probe_interval_secs(&self) -> Option { + self.0.tcp_keepalive_probe_interval_secs + } + + /// TCP keep alive probe count for mysql connection. + #[cfg(any(target_os = "linux", target_os = "macos",))] + pub fn get_tcp_keepalive_probe_count(&self) -> Option { + self.0.tcp_keepalive_probe_count + } + + /// TCP_USER_TIMEOUT time for mysql connection. + #[cfg(target_os = "linux")] + pub fn get_tcp_user_timeout_ms(&self) -> Option { + self.0.tcp_user_timeout + } + + /// Callback to handle requests for local files. + pub fn get_local_infile_handler(&self) -> Option<&LocalInfileHandler> { + self.0.local_infile_handler.as_ref() + } + + /// Tcp connect timeout (defaults to `None`). + pub fn get_tcp_connect_timeout(&self) -> Option { + self.0.tcp_connect_timeout + } + + /// Bind address for a client (defaults to `None`). + /// + /// Use carefully. Will probably make pool unusable because of *address already in use* + /// errors. + pub fn bind_address(&self) -> Option<&SocketAddr> { + self.0.bind_address.as_ref() + } + + /// Number of prepared statements cached on the client side (per connection). + /// Defaults to [`DEFAULT_STMT_CACHE_SIZE`]. + /// + /// Can be defined using `stmt_cache_size` connection url parameter. + pub fn get_stmt_cache_size(&self) -> usize { + self.0.stmt_cache_size + } + + /// If not `None`, then client will ask for compression if server supports it + /// (defaults to `None`). + /// + /// Can be defined using `compress` connection url parameter with values: + /// * `true` - library defined default compression level; + /// * `fast` - library defined fast compression level; + /// * `best` - library defined best compression level; + /// * `0`, `1`, ..., `9` - explicitly defined compression level where `0` stands for + /// "no compression"; + /// + /// Note that compression level defined here will affect only outgoing packets. + pub fn get_compress(&self) -> Option { + self.0.compress + } + + /// Additional client capabilities to set (defaults to empty). + /// + /// This value will be OR'ed with other client capabilities during connection initialization. + /// + /// ### Note + /// + /// It is a good way to set something like `CLIENT_FOUND_ROWS` but you should note that it + /// won't let you to interfere with capabilities managed by other options (like + /// `CLIENT_SSL` or `CLIENT_COMPRESS`). Also note that some capabilities are reserved, + /// pointless or may broke the connection, so this option should be used with caution. + pub fn get_additional_capabilities(&self) -> CapabilityFlags { + self.0.additional_capabilities + } + + /// Connect attributes (the default connect attributes are sent by default). + /// + /// This value is sent to the server as custom name-value attributes. + /// You can see them from performance_schema tables: [`session_account_connect_attrs` + /// and `session_connect_attrs`][attr_tables] when all of the following conditions + /// are met. + /// + /// * The server is MySQL 5.6 or later, or MariaDB 10.0 or later. + /// * [`performance_schema`] is on. + /// * [`performance_schema_session_connect_attrs_size`] is -1 or big enough + /// to store specified attributes. + /// + /// ### Note + /// + /// - set `connect_attrs` to `None` to completely remove connect attributes + /// - set `connect_attrs` to an empty map to send only the default attributes + /// + /// #### Warning + /// + /// > There is a bug in MySql 5.6 that kills COM_CHANGE_USER in the presence of connection + /// > attributes so it's better to stick to `None` for mysql < 5.7. + /// + /// Attribute names that begin with an underscore (`_`) are not set by + /// application programs because they are reserved for internal use. + /// + /// The following default attributes are sent in addition to ones set by programs. + /// + /// name | value + /// ----------------|-------------------------- + /// _client_name | The client library name (`rust-mysql-simple`) + /// _client_version | The client library version + /// _os | The operation system (`target_os` cfg feature) + /// _pid | The client process ID + /// _platform | The machine platform (`target_arch` cfg feature) + /// program_name | The first element of `std::env::args` if program_name isn't set by programs. + /// + /// [attr_tables]: https://dev.mysql.com/doc/refman/en/performance-schema-connection-attribute-tables.html + /// [`performance_schema`]: https://dev.mysql.com/doc/refman/8.0/en/performance-schema-system-variables.html#sysvar_performance_schema + /// [`performance_schema_session_connect_attrs_size`]: https://dev.mysql.com/doc/refman/en/performance-schema-system-variables.html#sysvar_performance_schema_session_connect_attrs_size + /// + pub fn get_connect_attrs(&self) -> Option<&HashMap> { + self.0.connect_attrs.as_ref() + } + + /// Disables `mysql_old_password` plugin (defaults to `true`). + /// + /// Available via `secure_auth` connection url parameter. + pub fn get_secure_auth(&self) -> bool { + self.0.secure_auth + } + + /// Returns `true` if `mysql_clear_password` plugin support is enabled (defaults to `false`). + /// + /// `mysql_clear_password` enables client to send passwords to the server as cleartext, without + /// hashing or encryption (consult MySql documentation for more info). + /// + /// # Security Notes + /// + /// Sending passwords as cleartext may be a security problem in some configurations. Please + /// consider using TLS or encrypted tunnels for server connection. + /// + /// # Connection URL + /// + /// Use `enable_cleartext_plugin` URL parameter to set this value. E.g. + /// + /// ``` + /// # use mysql::*; + /// # fn main() -> Result<()> { + /// let opts = Opts::from_url("mysql://localhost/db?enable_cleartext_plugin=true")?; + /// assert!(opts.get_enable_cleartext_plugin()); + /// # Ok(()) } + /// ``` + pub fn get_enable_cleartext_plugin(&self) -> bool { + self.0.enable_cleartext_plugin + } +} + +/// Provides a way to build [`Opts`](struct.Opts.html). +/// +/// ```ignore +/// let mut ssl_opts = SslOpts::default(); +/// ssl_opts = ssl_opts.with_pkcs12_path(Some(Path::new("/foo/cert.p12"))) +/// .with_root_ca_path(Some(Path::new("/foo/root_ca.der"))); +/// +/// // You can create new default builder +/// let mut builder = OptsBuilder::new(); +/// builder = builder.ip_or_hostname(Some("foo")) +/// .db_name(Some("bar")) +/// .ssl_opts(Some(ssl_opts)); +/// +/// // Or use existing T: Into +/// let builder = OptsBuilder::from_opts(existing_opts) +/// .ip_or_hostname(Some("foo")) +/// .db_name(Some("bar")); +/// ``` +/// +/// ## Connection URL +/// +/// `Opts` also could be constructed using connection URL. See docs on `OptsBuilder`'s methods for +/// the list of options available via URL. +/// +/// Example: +/// +/// ```ignore +/// let connection_opts = mysql::Opts::from_url("mysql://root:password@localhost:3307/mysql?prefer_socket=false").unwrap(); +/// let pool = mysql::Pool::new(connection_opts).unwrap(); +/// ``` +#[derive(Debug, Clone, PartialEq, Default)] +pub struct OptsBuilder { + opts: Opts, +} + +impl OptsBuilder { + pub fn new() -> Self { + OptsBuilder::default() + } + + pub fn from_opts>(opts: T) -> Self { + OptsBuilder { opts: opts.into() } + } + + /// Use a HashMap for creating an OptsBuilder instance: + /// ```ignore + /// OptsBuilder::new().from_hash_map(client); + /// ``` + /// `HashMap` key,value pairs: + /// - pool_min = upper bound for [`PoolConstraints`] + /// - pool_max = lower bound for [`PoolConstraints`] + /// - user = Username + /// - password = Password + /// - host = Host name or ip address + /// - port = Port, default is 3306 + /// - socket = Unix socket or pipe name(on windows) defaults to `None` + /// - db_name = Database name (defaults to `None`). + /// - prefer_socket = Prefer socket connection (defaults to `true`) + /// - tcp_keepalive_time_ms = TCP keep alive time for mysql connection (defaults to `None`) + /// - tcp_keepalive_probe_interval_secs = TCP keep alive interval between probes for mysql connection (defaults to `None`) + /// - tcp_keepalive_probe_count = TCP keep alive probe count for mysql connection (defaults to `None`) + /// - tcp_user_timeout_ms = TCP_USER_TIMEOUT time for mysql connection (defaults to `None`) + /// - compress = Compression level(defaults to `None`) + /// - tcp_connect_timeout_ms = Tcp connect timeout (defaults to `None`) + /// - stmt_cache_size = Number of prepared statements cached on the client side (per connection) + /// - secure_auth = Disable `mysql_old_password` auth plugin + /// + /// Login .cnf file parsing lib returns a HashMap for client configs + /// + /// **Note:** You do **not** have to use myloginrs lib. + pub fn from_hash_map(mut self, client: &HashMap) -> Result { + let mut pool_min = PoolConstraints::DEFAULT.min(); + let mut pool_max = PoolConstraints::DEFAULT.max(); + + for (key, value) in client.iter() { + match key.as_str() { + "pool_min" => match value.parse::() { + Ok(parsed) => pool_min = parsed, + Err(_) => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + }, + "pool_max" => match value.parse::() { + Ok(parsed) => pool_max = parsed, + Err(_) => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + }, + "user" => self.opts.0.user = Some(value.to_string()), + "password" => self.opts.0.pass = Some(value.to_string()), + "host" => { + let host = url::Host::parse(value) + .unwrap_or_else(|_| url::Host::Domain(value.to_owned())); + self.opts.0.ip_or_hostname = host; + } + "port" => match value.parse::() { + Ok(parsed) => self.opts.0.tcp_port = parsed, + Err(_) => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + }, + "socket" => self.opts.0.socket = Some(value.to_string()), + "db_name" => self.opts.0.db_name = Some(value.to_string()), + "prefer_socket" => { + //default to true like standard opts builder method + match value.parse::() { + Ok(parsed) => self.opts.0.prefer_socket = parsed, + Err(_) => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + } + } + "enable_cleartext_plugin" => match value.parse::() { + Ok(parsed) => self.opts.0.enable_cleartext_plugin = parsed, + Err(_) => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + }, + "secure_auth" => match value.parse::() { + Ok(parsed) => self.opts.0.secure_auth = parsed, + Err(_) => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + }, + "tcp_keepalive_time_ms" => { + //if cannot parse, default to none + self.opts.0.tcp_keepalive_time = match value.parse::() { + Ok(val) => Some(val), + _ => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + } + } + #[cfg(any(target_os = "linux", target_os = "macos",))] + "tcp_keepalive_probe_interval_secs" => { + //if cannot parse, default to none + self.opts.0.tcp_keepalive_probe_interval_secs = match value.parse::() { + Ok(val) => Some(val), + _ => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + } + } + #[cfg(any(target_os = "linux", target_os = "macos",))] + "tcp_keepalive_probe_count" => { + //if cannot parse, default to none + self.opts.0.tcp_keepalive_probe_count = match value.parse::() { + Ok(val) => Some(val), + _ => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + } + } + #[cfg(target_os = "linux")] + "tcp_user_timeout_ms" => { + self.opts.0.tcp_user_timeout = match value.parse::() { + Ok(val) => Some(val), + _ => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + } + } + "compress" => match value.parse::() { + Ok(val) => self.opts.0.compress = Some(Compression::new(val)), + Err(_) => { + //not an int + match value.as_str() { + "fast" => self.opts.0.compress = Some(Compression::fast()), + "best" => self.opts.0.compress = Some(Compression::best()), + "true" => self.opts.0.compress = Some(Compression::default()), + _ => { + return Err(UrlError::InvalidValue( + key.to_string(), + value.to_string(), + )); //should not go below this due to catch all + } + } + } + }, + "tcp_connect_timeout_ms" => { + self.opts.0.tcp_connect_timeout = match value.parse::() { + Ok(val) => Some(Duration::from_millis(val)), + _ => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + } + } + "stmt_cache_size" => match value.parse::() { + Ok(parsed) => self.opts.0.stmt_cache_size = parsed, + Err(_) => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + }, + "reset_connection" => match value.parse::() { + Ok(parsed) => { + self.opts.0.pool_opts = self.opts.0.pool_opts.with_reset_connection(parsed) + } + Err(_) => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + }, + "check_health" => match value.parse::() { + Ok(parsed) => { + self.opts.0.pool_opts = self.opts.0.pool_opts.with_check_health(parsed) + } + Err(_) => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + }, + "max_allowed_packet" => match value.parse::() { + Ok(parsed) => self.opts.0.max_allowed_packet = Some(parsed), + Err(_) => { + return Err(UrlError::InvalidValue(key.to_string(), value.to_string())) + } + }, + _ => { + //throw an error if there is an unrecognized param + return Err(UrlError::UnknownParameter(key.to_string())); + } + } + } + + if let Some(pool_constraints) = PoolConstraints::new(pool_min, pool_max) { + self.opts.0.pool_opts = self.opts.0.pool_opts.with_constraints(pool_constraints); + } else { + return Err(UrlError::InvalidPoolConstraints { + min: pool_min, + max: pool_max, + }); + } + + Ok(self) + } + + /// Address of mysql server (defaults to `127.0.0.1`). Host names should also work. + /// + /// **Note:** IPv6 addresses must be given in square brackets, e.g. `[::1]`. + pub fn ip_or_hostname>(mut self, ip_or_hostname: Option) -> Self { + let new = ip_or_hostname + .map(Into::into) + .unwrap_or_else(|| "127.0.0.1".into()); + self.opts.0.ip_or_hostname = + url::Host::parse(&new).unwrap_or_else(|_| url::Host::Domain(new.to_owned())); + self + } + + /// TCP port of mysql server (defaults to `3306`). + pub fn tcp_port(mut self, tcp_port: u16) -> Self { + self.opts.0.tcp_port = tcp_port; + self + } + + /// Socket path on unix or pipe name on windows (defaults to `None`). + /// + /// Can be defined using `socket` connection url parameter. + pub fn socket>(mut self, socket: Option) -> Self { + self.opts.0.socket = socket.map(Into::into); + self + } + + /// Defines `max_allowed_packet` option. See [`Opts::get_max_allowed_packet`]. + /// + /// Note that it'll saturate to proper minimum and maximum values + /// for this parameter (see MySql documentation). + pub fn max_allowed_packet(mut self, max_allowed_packet: Option) -> Self { + self.opts.0.max_allowed_packet = max_allowed_packet.map(|x| x.clamp(1024, 1073741824)); + self + } + + /// User (defaults to `None`). + pub fn user>(mut self, user: Option) -> Self { + self.opts.0.user = user.map(Into::into); + self + } + + /// Password (defaults to `None`). + pub fn pass>(mut self, pass: Option) -> Self { + self.opts.0.pass = pass.map(Into::into); + self + } + + /// Database name (defaults to `None`). + pub fn db_name>(mut self, db_name: Option) -> Self { + self.opts.0.db_name = db_name.map(Into::into); + self + } + + /// The timeout for each attempt to read from the server (defaults to `None`). + /// + /// Note that named pipe connection will ignore duration's `nanos`, and also note that + /// it is an error to pass the zero `Duration` to this method. + pub fn read_timeout(mut self, read_timeout: Option) -> Self { + self.opts.0.read_timeout = read_timeout; + self + } + + /// The timeout for each attempt to write to the server (defaults to `None`). + /// + /// Note that named pipe connection will ignore duration's `nanos`, and also note that + /// it is likely error to pass the zero `Duration` to this method. + pub fn write_timeout(mut self, write_timeout: Option) -> Self { + self.opts.0.write_timeout = write_timeout; + self + } + + /// TCP keep alive time for mysql connection (defaults to `None`). Available as + /// `tcp_keepalive_time_ms` url parameter. + /// + /// Can be defined using `tcp_keepalive_time_ms` connection url parameter. + pub fn tcp_keepalive_time_ms(mut self, tcp_keepalive_time_ms: Option) -> Self { + self.opts.0.tcp_keepalive_time = tcp_keepalive_time_ms; + self + } + + /// TCP keep alive interval between probes for mysql connection (defaults to `None`). Available as + /// `tcp_keepalive_probe_interval_secs` url parameter. + /// + /// Can be defined using `tcp_keepalive_probe_interval_secs` connection url parameter. + #[cfg(any(target_os = "linux", target_os = "macos",))] + pub fn tcp_keepalive_probe_interval_secs( + mut self, + tcp_keepalive_probe_interval_secs: Option, + ) -> Self { + self.opts.0.tcp_keepalive_probe_interval_secs = tcp_keepalive_probe_interval_secs; + self + } + + /// TCP keep alive probe count for mysql connection (defaults to `None`). Available as + /// `tcp_keepalive_probe_count` url parameter. + /// + /// Can be defined using `tcp_keepalive_probe_count` connection url parameter. + #[cfg(any(target_os = "linux", target_os = "macos",))] + pub fn tcp_keepalive_probe_count(mut self, tcp_keepalive_probe_count: Option) -> Self { + self.opts.0.tcp_keepalive_probe_count = tcp_keepalive_probe_count; + self + } + + /// TCP_USER_TIMEOUT for mysql connection (defaults to `None`). Available as + /// `tcp_user_timeout_ms` url parameter. + /// + /// Can be defined using `tcp_user_timeout_ms` connection url parameter. + #[cfg(target_os = "linux")] + pub fn tcp_user_timeout_ms(mut self, tcp_user_timeout_ms: Option) -> Self { + self.opts.0.tcp_user_timeout = tcp_user_timeout_ms; + self + } + + /// Set the `TCP_NODELAY` option for the mysql connection (defaults to `true`). + /// + /// Setting this option to false re-enables Nagle's algorithm, which can cause unusually high + /// latency (~40ms) but may increase maximum throughput. See #132. + pub fn tcp_nodelay(mut self, nodelay: bool) -> Self { + self.opts.0.tcp_nodelay = nodelay; + self + } + + /// Prefer socket connection (defaults to `true`). Available as `prefer_socket` url parameter + /// with value `true` or `false`. + /// + /// Will reconnect via socket (on named pipe on windows) after TCP connection + /// to `127.0.0.1` if `true`. + /// + /// Will fall back to TCP on error. Use `socket` option to enforce socket connection. + /// + /// Can be defined using `prefer_socket` connection url parameter. + pub fn prefer_socket(mut self, prefer_socket: bool) -> Self { + self.opts.0.prefer_socket = prefer_socket; + self + } + + /// Commands to execute on each new database connection. + pub fn init>(mut self, init: Vec) -> Self { + self.opts.0.init = init.into_iter().map(Into::into).collect(); + self + } + + /// Driver will require SSL connection if this option isn't `None` (default to `None`). + pub fn ssl_opts>>(mut self, ssl_opts: T) -> Self { + self.opts.0.ssl_opts = ssl_opts.into(); + self + } + + /// Supplies a trusted server RSA key for caching_sha2_password authentication. + pub fn server_public_key_path>>( + mut self, + path: Option, + ) -> Self { + self.opts.0.server_public_key_path = path.map(Into::into); + self + } + + /// Connection pool options (see [`Opts::get_pool_opts`]). + /// + /// Pass `None` to reset to default. + pub fn pool_opts>>(mut self, pool_opts: T) -> Self { + self.opts.0.pool_opts = pool_opts.into().unwrap_or_default(); + self + } + + /// Callback to handle requests for local files. These are + /// caused by using `LOAD DATA LOCAL INFILE` queries. The + /// callback is passed the filename, and a `Write`able object + /// to receive the contents of that file. + /// If unset, the default callback will read files relative to + /// the current directory. + pub fn local_infile_handler(mut self, handler: Option) -> Self { + self.opts.0.local_infile_handler = handler; + self + } + + /// Tcp connect timeout (defaults to `None`). Available as `tcp_connect_timeout_ms` + /// url parameter. + /// + /// Can be defined using `tcp_connect_timeout_ms` connection url parameter. + pub fn tcp_connect_timeout(mut self, timeout: Option) -> Self { + self.opts.0.tcp_connect_timeout = timeout; + self + } + + /// Bind address for a client (defaults to `None`). + /// + /// Use carefully. Will probably make pool unusable because of *address already in use* + /// errors. + pub fn bind_address(mut self, bind_address: Option) -> Self + where + T: Into, + { + self.opts.0.bind_address = bind_address.map(Into::into); + self + } + + /// Number of prepared statements cached on the client side (per connection). + /// Defaults to [`DEFAULT_STMT_CACHE_SIZE`]. + /// + /// Can be defined using `stmt_cache_size` connection url parameter. + /// + /// Call with `None` to reset to default. + pub fn stmt_cache_size(mut self, cache_size: T) -> Self + where + T: Into>, + { + self.opts.0.stmt_cache_size = cache_size.into().unwrap_or(128); + self + } + + /// If not `None`, then client will ask for compression if server supports it + /// (defaults to `None`). + /// + /// Can be defined using `compress` connection url parameter with values: + /// * `true` - library defined default compression level; + /// * `fast` - library defined fast compression level; + /// * `best` - library defined best compression level; + /// * `0`, `1`, ..., `9` - explicitly defined compression level where `0` stands for + /// "no compression"; + /// + /// Note that compression level defined here will affect only outgoing packets. + pub fn compress(mut self, compress: Option) -> Self { + self.opts.0.compress = compress; + self + } + + /// Additional client capabilities to set (defaults to empty). + /// + /// This value will be OR'ed with other client capabilities during connection initialization. + /// + /// ### Note + /// + /// It is a good way to set something like `CLIENT_FOUND_ROWS` but you should note that it + /// won't let you to interfere with capabilities managed by other options (like + /// `CLIENT_SSL` or `CLIENT_COMPRESS`). Also note that some capabilities are reserved, + /// pointless or may broke the connection, so this option should be used with caution. + pub fn additional_capabilities(mut self, additional_capabilities: CapabilityFlags) -> Self { + let forbidden_flags: CapabilityFlags = CapabilityFlags::CLIENT_PROTOCOL_41 + | CapabilityFlags::CLIENT_SSL + | CapabilityFlags::CLIENT_COMPRESS + | CapabilityFlags::CLIENT_SECURE_CONNECTION + | CapabilityFlags::CLIENT_LONG_PASSWORD + | CapabilityFlags::CLIENT_TRANSACTIONS + | CapabilityFlags::CLIENT_LOCAL_FILES + | CapabilityFlags::CLIENT_MULTI_STATEMENTS + | CapabilityFlags::CLIENT_MULTI_RESULTS + | CapabilityFlags::CLIENT_PS_MULTI_RESULTS; + + self.opts.0.additional_capabilities = additional_capabilities & !forbidden_flags; + self + } + + /// Connect attributes (the default connect attributes are sent by default). + /// + /// This value is sent to the server as custom name-value attributes. + /// You can see them from performance_schema tables: [`session_account_connect_attrs` + /// and `session_connect_attrs`][attr_tables] when all of the following conditions + /// are met. + /// + /// * The server is MySQL 5.6 or later, or MariaDB 10.0 or later. + /// * [`performance_schema`] is on. + /// * [`performance_schema_session_connect_attrs_size`] is -1 or big enough + /// to store specified attributes. + /// + /// ### Note + /// + /// - set `connect_attrs` to `None` to completely remove connect attributes + /// - set `connect_attrs` to an empty map to send only the default attributes + /// + /// #### Warning + /// + /// > There is a bug in MySql 5.6 that kills COM_CHANGE_USER in the presence of connection + /// > attributes so it's better to stick to `None` for mysql < 5.7. + /// + /// Attribute names that begin with an underscore (`_`) are not set by + /// application programs because they are reserved for internal use. + /// + /// The following default attributes are sent in addition to ones set by programs. + /// + /// name | value + /// ----------------|-------------------------- + /// _client_name | The client library name (`rust-mysql-simple`) + /// _client_version | The client library version + /// _os | The operation system (`target_os` cfg feature) + /// _pid | The client process ID + /// _platform | The machine platform (`target_arch` cfg feature) + /// program_name | The first element of `std::env::args` if program_name isn't set by programs. + /// + /// [attr_tables]: https://dev.mysql.com/doc/refman/en/performance-schema-connection-attribute-tables.html + /// [`performance_schema`]: https://dev.mysql.com/doc/refman/8.0/en/performance-schema-system-variables.html#sysvar_performance_schema + /// [`performance_schema_session_connect_attrs_size`]: https://dev.mysql.com/doc/refman/en/performance-schema-system-variables.html#sysvar_performance_schema_session_connect_attrs_size + /// + pub fn connect_attrs + Eq + Hash, T2: Into>( + mut self, + connect_attrs: Option>, + ) -> Self { + if let Some(connect_attrs) = connect_attrs { + let mut attrs = HashMap::with_capacity(connect_attrs.len()); + for (name, value) in connect_attrs { + let name = name.into(); + if !name.starts_with('_') { + attrs.insert(name, value.into()); + } + } + self.opts.0.connect_attrs = Some(attrs); + } else { + self.opts.0.connect_attrs = None; + } + self + } + + /// Disables `mysql_old_password` plugin (defaults to `true`). + /// + /// Available via `secure_auth` connection url parameter. + pub fn secure_auth(mut self, secure_auth: bool) -> Self { + self.opts.0.secure_auth = secure_auth; + self + } + + /// Enables Client-Side Cleartext Pluggable Authentication (defaults to `false`). + /// + /// Enables client to send passwords to the server as cleartext, without hashing or encryption + /// (consult MySql documentation for more info). + /// + /// # Security Notes + /// + /// Sending passwords as cleartext may be a security problem in some configurations. Please + /// consider using TLS or encrypted tunnels for server connection. + /// + /// # Connection URL + /// + /// Use `enable_cleartext_plugin` URL parameter to set this value. E.g. + /// + /// ``` + /// # use mysql::*; + /// # fn main() -> Result<()> { + /// let opts = Opts::from_url("mysql://localhost/db?enable_cleartext_plugin=true")?; + /// assert!(opts.get_enable_cleartext_plugin()); + /// # Ok(()) } + /// ``` + pub fn enable_cleartext_plugin(mut self, enable_cleartext_plugin: bool) -> Self { + self.opts.0.enable_cleartext_plugin = enable_cleartext_plugin; + self + } +} + +impl From for Opts { + fn from(builder: OptsBuilder) -> Opts { + builder.opts + } +} + +fn get_opts_user_from_url(url: &Url) -> Option { + let user = url.username(); + if !user.is_empty() { + Some( + percent_decode(user.as_ref()) + .decode_utf8_lossy() + .into_owned(), + ) + } else { + None + } +} + +fn get_opts_pass_from_url(url: &Url) -> Option { + url.password().map(|pass| { + percent_decode(pass.as_ref()) + .decode_utf8_lossy() + .into_owned() + }) +} + +fn get_opts_db_name_from_url(url: &Url) -> Option { + if let Some(mut segments) = url.path_segments() { + segments + .next() + .filter(|&db_name| !db_name.is_empty()) + .map(|db_name| { + percent_decode(db_name.as_ref()) + .decode_utf8_lossy() + .into_owned() + }) + } else { + None + } +} + +fn from_url_basic(url_str: &str) -> Result<(Opts, Vec<(String, String)>), UrlError> { + let url = Url::parse(url_str)?; + if url.scheme() != "mysql" { + return Err(UrlError::UnsupportedScheme(url.scheme().to_string())); + } + if url.cannot_be_a_base() { + return Err(UrlError::BadUrl); + } + let user = get_opts_user_from_url(&url); + let pass = get_opts_pass_from_url(&url); + let ip_or_hostname = url + .host() + .ok_or(UrlError::BadUrl) + .and_then(|host| url::Host::parse(&host.to_string()).map_err(|_| UrlError::BadUrl))?; + let tcp_port = url.port().unwrap_or(3306); + let db_name = get_opts_db_name_from_url(&url); + + let query_pairs = url.query_pairs().into_owned().collect(); + let opts = Opts(Box::new(InnerOpts { + user, + pass, + ip_or_hostname, + tcp_port, + db_name, + ..InnerOpts::default() + })); + + Ok((opts, query_pairs)) +} + +fn from_url(url: &str) -> Result { + let (opts, query_pairs) = from_url_basic(url)?; + let hash_map = query_pairs.into_iter().collect::>(); + OptsBuilder::from_opts(opts) + .from_hash_map(&hash_map) + .map(Into::into) +} + +/// [`COM_CHANGE_USER`][1] options. +/// +/// Connection [`Opts`] are going to be updated accordingly upon `COM_CHANGE_USER`. +/// +/// [`Opts`] won't be updated by default, because default `ChangeUserOpts` will reuse +/// connection's `user`, `pass` and `db_name`. +/// +/// [1]: https://dev.mysql.com/doc/c-api/5.7/en/mysql-change-user.html +#[derive(Clone, Eq, PartialEq)] +pub struct ChangeUserOpts { + user: Option>, + pass: Option>, + db_name: Option>, +} + +impl ChangeUserOpts { + pub const DEFAULT: Self = Self { + user: None, + pass: None, + db_name: None, + }; + + pub(crate) fn update_opts(self, opts: &mut Opts) { + if self.user.is_none() && self.pass.is_none() && self.db_name.is_none() { + return; + } + + let mut builder = OptsBuilder::from_opts(opts.clone()); + + if let Some(user) = self.user { + builder = builder.user(user); + } + + if let Some(pass) = self.pass { + builder = builder.pass(pass); + } + + if let Some(db_name) = self.db_name { + builder = builder.db_name(db_name); + } + + *opts = Opts::from(builder); + } + + /// Creates change user options that'll reuse connection options. + pub fn new() -> Self { + Self { + user: None, + pass: None, + db_name: None, + } + } + + /// Set [`Opts::get_user`] to the given value. + pub fn with_user(mut self, user: Option) -> Self { + self.user = Some(user); + self + } + + /// Set [`Opts::get_pass`] to the given value. + pub fn with_pass(mut self, pass: Option) -> Self { + self.pass = Some(pass); + self + } + + /// Set [`Opts::get_db_name`] to the given value. + pub fn with_db_name(mut self, db_name: Option) -> Self { + self.db_name = Some(db_name); + self + } + + /// Returns user. + /// + /// * if `None` then `self` does not meant to change user + /// * if `Some(None)` then `self` will clear user + /// * if `Some(Some(_))` then `self` will change user + pub fn user(&self) -> Option> { + self.user.as_ref().map(|x| x.as_deref()) + } + + /// Returns password. + /// + /// * if `None` then `self` does not meant to change password + /// * if `Some(None)` then `self` will clear password + /// * if `Some(Some(_))` then `self` will change password + pub fn pass(&self) -> Option> { + self.pass.as_ref().map(|x| x.as_deref()) + } + + /// Returns database name. + /// + /// * if `None` then `self` does not meant to change database name + /// * if `Some(None)` then `self` will clear database name + /// * if `Some(Some(_))` then `self` will change database name + pub fn db_name(&self) -> Option> { + self.db_name.as_ref().map(|x| x.as_deref()) + } +} + +impl Default for ChangeUserOpts { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Debug for ChangeUserOpts { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ChangeUserOpts") + .field("user", &self.user) + .field( + "pass", + &self.pass.as_ref().map(|x| x.as_ref().map(|_| "...")), + ) + .field("db_name", &self.db_name) + .finish() + } +} + +#[cfg(test)] +mod test { + use mysql_common::proto::codec::Compression; + use std::time::Duration; + + use super::{InnerOpts, Opts, OptsBuilder}; + + #[allow(dead_code)] + fn assert_conn_from_url_opts_optsbuilder(url: &str, opts: Opts, opts_builder: OptsBuilder) { + crate::Conn::new(url).unwrap(); + crate::Conn::new(opts.clone()).unwrap(); + crate::Conn::new(opts_builder.clone()).unwrap(); + crate::Pool::new(url).unwrap(); + crate::Pool::new(opts).unwrap(); + crate::Pool::new(opts_builder).unwrap(); + } + + #[test] + fn should_report_empty_url_database_as_none() { + let opt = Opts::from_url("mysql://localhost/").unwrap(); + assert_eq!(opt.get_db_name(), None); + } + + #[test] + fn should_convert_url_into_opts() { + #[cfg(any(target_os = "linux", target_os = "macos",))] + let tcp_keepalive_probe_interval_secs = "&tcp_keepalive_probe_interval_secs=8"; + #[cfg(not(any(target_os = "linux", target_os = "macos",)))] + let tcp_keepalive_probe_interval_secs = ""; + + #[cfg(any(target_os = "linux", target_os = "macos",))] + let tcp_keepalive_probe_count = "&tcp_keepalive_probe_count=5"; + #[cfg(not(any(target_os = "linux", target_os = "macos",)))] + let tcp_keepalive_probe_count = ""; + + #[cfg(target_os = "linux")] + let tcp_user_timeout = "&tcp_user_timeout_ms=6000"; + #[cfg(not(target_os = "linux"))] + let tcp_user_timeout = ""; + + let opts = format!( + "mysql://us%20r:p%20w@localhost:3308/db%2dname?prefer_socket=false&tcp_keepalive_time_ms=5000{}{}{}&socket=%2Ftmp%2Fmysql.sock&compress=8", + tcp_keepalive_probe_interval_secs, + tcp_keepalive_probe_count, + tcp_user_timeout, + ); + assert_eq!( + Opts(Box::new(InnerOpts { + user: Some("us r".to_string()), + pass: Some("p w".to_string()), + ip_or_hostname: url::Host::Domain("localhost".to_string()), + tcp_port: 3308, + db_name: Some("db-name".to_string()), + prefer_socket: false, + tcp_keepalive_time: Some(5000), + #[cfg(any(target_os = "linux", target_os = "macos",))] + tcp_keepalive_probe_interval_secs: Some(8), + #[cfg(any(target_os = "linux", target_os = "macos",))] + tcp_keepalive_probe_count: Some(5), + #[cfg(target_os = "linux")] + tcp_user_timeout: Some(6000), + socket: Some("/tmp/mysql.sock".into()), + compress: Some(Compression::new(8)), + ..InnerOpts::default() + })), + Opts::from_url(&opts).unwrap(), + ); + } + + #[test] + #[should_panic] + fn should_panic_on_invalid_url() { + let opts = "42"; + Opts::from_url(opts).unwrap(); + } + + #[test] + #[should_panic] + fn should_panic_on_invalid_scheme() { + let opts = "postgres://localhost"; + Opts::from_url(opts).unwrap(); + } + + #[test] + #[should_panic] + fn should_panic_on_unknown_query_param() { + let opts = "mysql://localhost/foo?bar=baz"; + Opts::from_url(opts).unwrap(); + } + + #[test] + fn should_read_hashmap_into_opts() { + use crate::OptsBuilder; + macro_rules! map( + { $($key:expr => $value:expr), + }=> { + { + let mut h = std::collections::HashMap::new(); + $( + h.insert($key, $value); + )+ + h + } + }; + ); + + let mut cnf_map = map! { + "user".to_string() => "test".to_string(), + "password".to_string() => "password".to_string(), + "host".to_string() => "127.0.0.1".to_string(), + "port".to_string() => "8080".to_string(), + "db_name".to_string() => "test_db".to_string(), + "prefer_socket".to_string() => "false".to_string(), + "tcp_keepalive_time_ms".to_string() => "5000".to_string(), + "compress".to_string() => "best".to_string(), + "tcp_connect_timeout_ms".to_string() => "1000".to_string(), + "stmt_cache_size".to_string() => "33".to_string(), + "max_allowed_packet".to_string() => "65536".to_string() + }; + #[cfg(any(target_os = "linux", target_os = "macos",))] + cnf_map.insert( + "tcp_keepalive_probe_interval_secs".to_string(), + "8".to_string(), + ); + #[cfg(any(target_os = "linux", target_os = "macos",))] + cnf_map.insert("tcp_keepalive_probe_count".to_string(), "5".to_string()); + + let parsed_opts = OptsBuilder::new().from_hash_map(&cnf_map).unwrap(); + + assert_eq!(parsed_opts.opts.get_user(), Some("test")); + assert_eq!(parsed_opts.opts.get_pass(), Some("password")); + assert_eq!(parsed_opts.opts.get_ip_or_hostname(), "127.0.0.1"); + assert_eq!(parsed_opts.opts.get_tcp_port(), 8080); + assert_eq!(parsed_opts.opts.get_db_name(), Some("test_db")); + assert_eq!(parsed_opts.opts.get_max_allowed_packet(), Some(65536)); + assert!(!parsed_opts.opts.get_prefer_socket()); + assert_eq!(parsed_opts.opts.get_tcp_keepalive_time_ms(), Some(5000)); + #[cfg(any(target_os = "linux", target_os = "macos",))] + assert_eq!( + parsed_opts.opts.get_tcp_keepalive_probe_interval_secs(), + Some(8) + ); + #[cfg(any(target_os = "linux", target_os = "macos",))] + assert_eq!(parsed_opts.opts.get_tcp_keepalive_probe_count(), Some(5)); + assert_eq!( + parsed_opts.opts.get_compress(), + Some(crate::Compression::best()) + ); + assert_eq!( + parsed_opts.opts.get_tcp_connect_timeout(), + Some(Duration::from_millis(1000)) + ); + assert_eq!(parsed_opts.opts.get_stmt_cache_size(), 33); + } + + #[test] + fn should_have_url_err() { + use crate::OptsBuilder; + use crate::UrlError; + macro_rules! map( + { $($key:expr => $value:expr), + }=> { + { + let mut h = std::collections::HashMap::new(); + $( + h.insert($key, $value); + )+ + h + } + }; + ); + + let cnf_map = map! { + "user".to_string() => "test".to_string(), + "password".to_string() => "password".to_string(), + "host".to_string() => "127.0.0.1".to_string(), + "port".to_string() => "NOTAPORT".to_string(), + "db_name".to_string() => "test_db".to_string(), + "prefer_socket".to_string() => "false".to_string(), + "tcp_keepalive_time_ms".to_string() => "5000".to_string(), + "compress".to_string() => "best".to_string(), + "tcp_connect_timeout_ms".to_string() => "1000".to_string(), + "stmt_cache_size".to_string() => "33".to_string() + }; + + let parsed = OptsBuilder::new().from_hash_map(&cnf_map); + assert_eq!( + parsed, + Err(UrlError::InvalidValue( + "port".to_string(), + "NOTAPORT".to_string() + )) + ); + } +} diff --git a/vendor/mysql-28.0.0/src/conn/opts/native_tls_opts.rs b/vendor/mysql-28.0.0/src/conn/opts/native_tls_opts.rs new file mode 100644 index 0000000000..95e0fec34e --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/opts/native_tls_opts.rs @@ -0,0 +1,51 @@ +#![cfg(feature = "native-tls")] + +use native_tls::Identity; + +use std::{borrow::Cow, path::Path}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ClientIdentity { + pkcs12_path: Cow<'static, Path>, + password: Option>, +} + +impl ClientIdentity { + /// Creates new identity with the given path to the pkcs12 archive. + pub fn new(pkcs12_path: T) -> Self + where + T: Into>, + { + Self { + pkcs12_path: pkcs12_path.into(), + password: None, + } + } + + /// Sets the archive password. + pub fn with_password(mut self, pass: T) -> Self + where + T: Into>, + { + self.password = Some(pass.into()); + self + } + + /// Returns the pkcs12 archive path. + pub fn pkcs12_path(&self) -> &Path { + self.pkcs12_path.as_ref() + } + + /// Returns the archive password. + pub fn password(&self) -> Option<&str> { + self.password.as_ref().map(AsRef::as_ref) + } + + pub(crate) fn load(&self) -> crate::Result { + let der = std::fs::read(self.pkcs12_path.as_ref())?; + Ok(Identity::from_pkcs12( + &der, + self.password.as_deref().unwrap_or(""), + )?) + } +} diff --git a/vendor/mysql-28.0.0/src/conn/opts/pool_opts.rs b/vendor/mysql-28.0.0/src/conn/opts/pool_opts.rs new file mode 100644 index 0000000000..b557b3db27 --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/opts/pool_opts.rs @@ -0,0 +1,214 @@ +// Copyright (c) 2023 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +macro_rules! const_assert { + ($name:ident, $($xs:expr),+ $(,)*) => { + #[allow(unknown_lints, clippy::eq_op)] + const $name: [(); 0 - !($($xs)&&+) as usize] = []; + }; +} + +/// Connection pool options. +/// +/// ``` +/// # use mysql::{PoolOpts, PoolConstraints}; +/// # use std::time::Duration; +/// let pool_opts = PoolOpts::default() +/// .with_constraints(PoolConstraints::new(15, 30).unwrap()) +/// .with_reset_connection(false); +/// ``` +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct PoolOpts { + constraints: PoolConstraints, + reset_connection: bool, + check_health: bool, +} + +impl PoolOpts { + /// Calls `Self::default`. + pub fn new() -> Self { + Self::default() + } + + /// Creates the default [`PoolOpts`] with the given constraints. + pub fn with_constraints(mut self, constraints: PoolConstraints) -> Self { + self.constraints = constraints; + self + } + + /// Returns pool constraints. + pub fn constraints(&self) -> PoolConstraints { + self.constraints + } + + /// Sets whether to reset the connection upon returning it to a pool (defaults to `true`). + /// + /// Default behavior increases reliability but comes with cons: + /// + /// * reset procedure removes all prepared statements, i.e. kills prepared statements cache + /// * connection reset is quite fast but requires additional client-server roundtrip + /// (might require re-authentication for older servers) + /// + /// The purpose of the reset procedure is to: + /// + /// * rollback any opened transactions + /// * reset transaction isolation level + /// * reset session variables + /// * delete user variables + /// * remove temporary tables + /// * remove all PREPARE statement (this action kills prepared statements cache) + /// + /// So to increase overall performance you can safely opt-out of the default behavior + /// if you are not willing to change the session state in an unpleasant way. + /// + /// It is also possible to selectively opt-in/out using [`crate::PooledConn::reset_connection`]. + /// + /// # Connection URL + /// + /// You can use `reset_connection` URL parameter to set this value. E.g. + /// + /// ``` + /// # use mysql::*; + /// # use std::time::Duration; + /// # fn main() -> Result<()> { + /// let opts = Opts::from_url("mysql://localhost/db?reset_connection=false")?; + /// assert_eq!(opts.get_pool_opts().reset_connection(), false); + /// # Ok(()) } + /// ``` + pub fn with_reset_connection(mut self, reset_connection: bool) -> Self { + self.reset_connection = reset_connection; + self + } + + /// Returns the `reset_connection` value (see [`PoolOpts::with_reset_connection`]). + pub fn reset_connection(&self) -> bool { + self.reset_connection + } + + /// Sets whether to check connection health upon retrieving it from a pool (defaults to `true`). + /// + /// If `true`, then `Conn::ping` will be invoked on a non-fresh pooled connection. + /// + /// # Connection URL + /// + /// Use `check_health` URL parameter to set this value. E.g. + /// + /// ``` + /// # use mysql::*; + /// # use std::time::Duration; + /// # fn main() -> Result<()> { + /// let opts = Opts::from_url("mysql://localhost/db?check_health=false")?; + /// assert_eq!(opts.get_pool_opts().check_health(), false); + /// # Ok(()) } + /// ``` + pub fn with_check_health(mut self, check_health: bool) -> Self { + self.check_health = check_health; + self + } + + pub fn check_health(&self) -> bool { + self.check_health + } +} + +impl Default for PoolOpts { + fn default() -> Self { + Self { + constraints: PoolConstraints::DEFAULT, + reset_connection: true, + check_health: true, + } + } +} + +/// Connection pool constraints. +/// +/// This type stores `min` and `max` constraints for [`crate::Pool`] and ensures that `min <= max`. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct PoolConstraints { + min: usize, + max: usize, +} + +const_assert!( + _DEFAULT_POOL_CONSTRAINTS_ARE_CORRECT, + PoolConstraints::DEFAULT.min <= PoolConstraints::DEFAULT.max, +); + +const_assert!( + _POOL_CONSTRAINTS_MAX_IS_NONZERO, + PoolConstraints::DEFAULT.max > 0 +); + +pub struct Assert; +impl Assert { + pub const LEQ: usize = R - L; +} + +#[allow(path_statements)] +pub const fn gte() { + #[allow(clippy::no_effect)] + Assert::::LEQ; +} + +impl PoolConstraints { + /// Default pool constraints. + pub const DEFAULT: PoolConstraints = PoolConstraints { min: 10, max: 100 }; + + /// Creates new [`PoolConstraints`] if constraints are valid (`min <= max`). + /// + /// # Connection URL + /// + /// You can use `pool_min` and `pool_max` URL parameters to define pool constraints. + /// + /// ``` + /// # use mysql::*; + /// # fn main() -> Result<()> { + /// let opts = Opts::from_url("mysql://localhost/db?pool_min=0&pool_max=151")?; + /// assert_eq!(opts.get_pool_opts().constraints(), PoolConstraints::new(0, 151).unwrap()); + /// # Ok(()) } + /// ``` + pub fn new(min: usize, max: usize) -> Option { + match (min, max) { + (0, 0) => None, + (min, max) if min <= max => Some(PoolConstraints { min, max }), + _ => None, + } + } + + pub const fn new_const() -> PoolConstraints { + gte::(); + + assert!(MAX > 0); + + PoolConstraints { min: MIN, max: MAX } + } + + /// Lower bound of this pool constraints. + pub const fn min(&self) -> usize { + self.min + } + + /// Upper bound of this pool constraints. + pub const fn max(&self) -> usize { + self.max + } +} + +impl Default for PoolConstraints { + fn default() -> Self { + PoolConstraints::DEFAULT + } +} + +impl From for (usize, usize) { + /// Transforms constraints to a pair of `(min, max)`. + fn from(PoolConstraints { min, max }: PoolConstraints) -> Self { + (min, max) + } +} diff --git a/vendor/mysql-28.0.0/src/conn/opts/rustls_opts.rs b/vendor/mysql-28.0.0/src/conn/opts/rustls_opts.rs new file mode 100644 index 0000000000..3db8f289a2 --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/opts/rustls_opts.rs @@ -0,0 +1,153 @@ +#![cfg(feature = "rustls")] + +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs1KeyDer}; +use rustls_pemfile::{certs, ec_private_keys, pkcs8_private_keys, rsa_private_keys}; + +use std::{borrow::Cow, path::Path}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ClientIdentity { + cert_chain_path: Cow<'static, Path>, + priv_key_path: Cow<'static, Path>, +} + +impl ClientIdentity { + /// Creates new identity. + /// + /// `cert_chain_path` - path to a certificate chain (in PEM or DER) + /// `priv_key_path` - path to a private key (in DER or PEM) (it'll take the first one) + pub fn new(cert_chain_path: T, priv_key_path: U) -> Self + where + T: Into>, + U: Into>, + { + Self { + cert_chain_path: cert_chain_path.into(), + priv_key_path: priv_key_path.into(), + } + } + + /// Sets the certificate chain path (in DER or PEM). + pub fn with_cert_chain_path(mut self, cert_chain_path: T) -> Self + where + T: Into>, + { + self.cert_chain_path = cert_chain_path.into(); + self + } + + /// Sets the private key path (in DER or PEM) (it'll take the first one). + pub fn with_priv_key_path(mut self, priv_key_path: T) -> Self + where + T: Into>, + { + self.priv_key_path = priv_key_path.into(); + self + } + + /// Returns the certificate chain path. + pub fn cert_chain_path(&self) -> &Path { + self.cert_chain_path.as_ref() + } + + /// Returns the private key path. + pub fn priv_key_path(&self) -> &Path { + self.priv_key_path.as_ref() + } + + pub(crate) fn load( + &self, + ) -> crate::Result<(Vec>, PrivateKeyDer<'static>)> { + let cert_data = std::fs::read(self.cert_chain_path.as_ref())?; + let key_data = std::fs::read(self.priv_key_path.as_ref())?; + + let mut cert_chain = Vec::new(); + for cert in certs(&mut &*cert_data) { + cert_chain.push(cert?.to_owned()); + } + if cert_chain.is_empty() && !cert_data.is_empty() { + cert_chain.push(CertificateDer::from(cert_data)); + } + + let mut priv_key = None; + + for key in rsa_private_keys(&mut &*key_data).take(1) { + priv_key = Some(PrivateKeyDer::Pkcs1(key?.clone_key())); + } + + if priv_key.is_none() { + for key in pkcs8_private_keys(&mut &*key_data).take(1) { + priv_key = Some(PrivateKeyDer::Pkcs8(key?.clone_key())) + } + } + + if priv_key.is_none() { + for key in ec_private_keys(&mut &*key_data).take(1) { + priv_key = Some(PrivateKeyDer::Sec1(key?.clone_key())) + } + } + + if let Some(priv_key) = priv_key { + return Ok((cert_chain, priv_key)); + } + + match PrivateKeyDer::try_from(key_data.as_slice()) { + Ok(key) => Ok((cert_chain, key.clone_key())), + Err(_) => Ok(( + cert_chain, + PrivateKeyDer::Pkcs1(PrivatePkcs1KeyDer::from(key_data)), + )), + } + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use rustls::pki_types::PrivateKeyDer; + + use crate::ClientIdentity; + + #[test] + fn load_pkcs1() { + let (_certs, key_pem) = ClientIdentity::new( + Path::new("tests/client.crt"), + Path::new("tests/client-key.pem"), + ) + .load() + .unwrap(); + assert!(matches!(key_pem, PrivateKeyDer::Pkcs1(_))); + + let (_certs, key_der) = ClientIdentity::new( + Path::new("tests/client.crt"), + Path::new("tests/client-key.pem"), + ) + .load() + .unwrap(); + assert!(matches!(key_der, PrivateKeyDer::Pkcs1(_))); + + assert_eq!(key_der, key_pem); + } + + #[test] + fn load_pkcs8() { + let (_certs, key_der) = ClientIdentity::new( + Path::new("tests/client.crt"), + Path::new("tests/client-key.pkcs8.der"), + ) + .load() + .unwrap(); + assert!(matches!(key_der, PrivateKeyDer::Pkcs8(_))); + + let (_certs, key_pem) = ClientIdentity::new( + Path::new("tests/client.crt"), + Path::new("tests/client-key.pkcs8.pem"), + ) + .load() + .unwrap(); + assert!(matches!(key_pem, PrivateKeyDer::Pkcs8(_))); + + assert_eq!(key_der, key_pem); + } +} diff --git a/vendor/mysql-28.0.0/src/conn/pool/inner.rs b/vendor/mysql-28.0.0/src/conn/pool/inner.rs new file mode 100644 index 0000000000..d272e4133c --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/pool/inner.rs @@ -0,0 +1,109 @@ +use std::{ + collections::VecDeque, + sync::{ + atomic::{AtomicUsize, Ordering}, + Condvar, Mutex, + }, +}; + +use crate::{Conn, Opts, PoolOpts}; + +#[derive(Debug)] +pub struct Protected { + opts: Opts, + connections: VecDeque, +} + +impl Protected { + fn new(opts: Opts) -> crate::Result { + let constraints = opts.get_pool_opts().constraints(); + + let mut this = Protected { + connections: VecDeque::with_capacity(constraints.max()), + opts, + }; + + for _ in 0..constraints.min() { + this.new_conn()?; + } + + Ok(this) + } + + pub fn new_conn(&mut self) -> crate::Result<()> { + match Conn::new(self.opts.clone()) { + Ok(conn) => { + self.connections.push_back(conn); + Ok(()) + } + Err(err) => Err(err), + } + } + + pub fn take_by_query(&mut self, query: &[u8]) -> Option { + match self + .connections + .iter() + .position(|conn| conn.has_stmt(query)) + { + Some(position) => self.connections.swap_remove_back(position), + None => None, + } + } + + pub fn pop_front(&mut self) -> Option { + self.connections.pop_front() + } + + pub fn push_back(&mut self, conn: Conn) { + self.connections.push_back(conn) + } +} + +pub struct Inner { + protected: (Mutex, Condvar), + pool_opts: PoolOpts, + count: AtomicUsize, +} + +impl Inner { + pub fn increase(&self) { + let prev = self.count.fetch_add(1, Ordering::Relaxed); + debug_assert!(prev < self.max_constraint()); + } + + pub fn decrease(&self) { + let prev = self.count.fetch_sub(1, Ordering::Relaxed); + debug_assert!(prev > 0); + } + + pub fn count(&self) -> usize { + let value = self.count.load(Ordering::Relaxed); + debug_assert!(value <= self.max_constraint()); + value + } + + pub fn is_full(&self) -> bool { + self.count() == self.max_constraint() + } + + pub fn opts(&self) -> &PoolOpts { + &self.pool_opts + } + + pub fn max_constraint(&self) -> usize { + self.pool_opts.constraints().max() + } + + pub fn protected(&self) -> &(Mutex, Condvar) { + &self.protected + } + + pub fn new(opts: Opts) -> crate::Result { + Ok(Self { + count: AtomicUsize::new(opts.get_pool_opts().constraints().min()), + pool_opts: opts.get_pool_opts().clone(), + protected: (Mutex::new(Protected::new(opts)?), Condvar::new()), + }) + } +} diff --git a/vendor/mysql-28.0.0/src/conn/pool/mod.rs b/vendor/mysql-28.0.0/src/conn/pool/mod.rs new file mode 100644 index 0000000000..2eae3681a9 --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/pool/mod.rs @@ -0,0 +1,796 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use std::{ + fmt, + ops::Deref, + sync::Arc, + time::{Duration, Instant}, +}; + +use crate::{ + conn::query_result::{Binary, Text}, + prelude::*, + ChangeUserOpts, Conn, DriverError, LocalInfileHandler, Opts, Params, QueryResult, Result, + Statement, Transaction, TxOpts, +}; + +mod inner; + +/// Thread-safe cloneable smart pointer to a connection pool. +/// +/// However you can prepare statements directly on `Pool` without +/// invoking [`Pool::get_conn`](struct.Pool.html#method.get_conn). +/// +/// `Pool` will hold at least `min` connections and will create as many as `max` +/// connections with possible overhead of one connection per alive thread. +/// +/// Example of multithreaded `Pool` usage: +/// +/// ```rust +/// # mysql::doctest_wrapper!(__result, { +/// # use mysql::*; +/// # use mysql::prelude::*; +/// # let mut conn = Conn::new(get_opts())?; +/// # let pool_opts = PoolOpts::new().with_constraints(PoolConstraints::new_const::<5, 10>()); +/// # let opts = get_opts().pool_opts(pool_opts); +/// let pool = Pool::new(opts).unwrap(); +/// let mut threads = Vec::new(); +/// +/// for _ in 0..1000 { +/// let pool = pool.clone(); +/// threads.push(std::thread::spawn(move || { +/// let mut conn = pool.get_conn().unwrap(); +/// let result: u8 = conn.query_first("SELECT 1").unwrap().unwrap(); +/// assert_eq!(result, 1_u8); +/// })); +/// } +/// +/// for t in threads.into_iter() { +/// assert!(t.join().is_ok()); +/// } +/// # }); +/// ``` +/// +/// For more info on how to work with mysql connection please look at +/// [`PooledConn`](struct.PooledConn.html) documentation. +#[derive(Clone)] +pub struct Pool { + inner: Arc, +} + +impl Pool { + /// Will return connection taken from a pool. + /// + /// Will wait til timeout if `timeout_ms` is `Some(_)` + fn _get_conn>( + &self, + stmt: Option, + timeout: Option, + mut call_ping: bool, + ) -> Result { + let times = timeout.map(|timeout| (Instant::now(), timeout)); + + let (protected, condvar) = self.inner.protected(); + + let conn = if !self.inner.opts().reset_connection() { + // stmt cache considered enabled if reset_connection is false + if let Some(ref query) = stmt { + protected.lock()?.take_by_query(query.as_ref()) + } else { + None + } + } else { + None + }; + + let mut conn = if let Some(conn) = conn { + conn + } else { + let mut protected = protected.lock()?; + loop { + if let Some(conn) = protected.pop_front() { + drop(protected); + break conn; + } else if self.inner.is_full() { + protected = if let Some((start, timeout)) = times { + if start.elapsed() > timeout { + return Err(DriverError::Timeout.into()); + } + condvar.wait_timeout(protected, timeout)?.0 + } else { + condvar.wait(protected)? + } + } else { + protected.new_conn()?; + self.inner.increase(); + // we do not have to call ping for a fresh connection + call_ping = false; + } + } + }; + + if call_ping && self.inner.opts().check_health() && conn.ping().is_err() { + // existing connection seem to be dead, retrying.. + self.inner.decrease(); + return self._get_conn(stmt, timeout, call_ping); + } + + Ok(PooledConn { + pool: self.clone(), + conn: Some(conn), + }) + } + + /// Creates new pool with the given options (see [`Opts`]). + pub fn new(opts: T) -> Result + where + Opts: TryFrom, + crate::Error: From, + { + Ok(Pool { + inner: Arc::new(inner::Inner::new(Opts::try_from(opts)?)?), + }) + } + + /// Gives you a [`PooledConn`](struct.PooledConn.html). + pub fn get_conn(&self) -> Result { + self._get_conn(None::, None, true) + } + + /// Will try to get connection for the duration of `timeout`. + /// + /// # Failure + /// This function will return `Error::DriverError(DriverError::Timeout)` if timeout was + /// reached while waiting for new connection to become available. + pub fn try_get_conn(&self, timeout: Duration) -> Result { + self._get_conn(None::, Some(timeout), true) + } + + /// Shortcut for `pool.get_conn()?.start_transaction(..)`. + pub fn start_transaction(&self, tx_opts: TxOpts) -> Result> { + let conn = self._get_conn(None::, None, false)?; + let result = conn.pooled_start_transaction(tx_opts); + match result { + Ok(trans) => Ok(trans), + Err(ref e) if e.is_connectivity_error() => { + let conn = self._get_conn(None::, None, true)?; + conn.pooled_start_transaction(tx_opts) + } + Err(e) => Err(e), + } + } +} + +impl fmt::Debug for Pool { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Pool {{ constraints: {:?}, count: {} }}", + self.inner.opts().constraints(), + self.inner.count(), + ) + } +} + +/// Pooled mysql connection. +/// +/// You should prefer using `prep` along `exec` instead of `query` from the Queryable trait where +/// possible, except cases when statement has no params and when it has no return values or return +/// values which evaluates to `Value::Bytes`. +/// +/// `query` is a part of mysql text protocol, so under the hood you will always receive +/// `Value::Bytes` as a result and `from_value` will need to parse it if you want, for example, `i64` +/// +/// ```rust +/// # mysql::doctest_wrapper!(__result, { +/// # use mysql::*; +/// # use mysql::prelude::*; +/// # let mut conn = Conn::new(get_opts())?; +/// let pool = Pool::new(get_opts()).unwrap(); +/// let mut conn = pool.get_conn().unwrap(); +/// +/// conn.query_first("SELECT 42").map(|result: Option| { +/// let result = result.unwrap(); +/// assert_eq!(result, Value::Bytes(b"42".to_vec())); +/// assert_eq!(from_value::(result), 42i64); +/// }).unwrap(); +/// conn.exec_iter("SELECT 42", ()).map(|mut result| { +/// let cell = result.next().unwrap().unwrap().take(0).unwrap(); +/// assert_eq!(cell, Value::Int(42i64)); +/// assert_eq!(from_value::(cell), 42i64); +/// }).unwrap(); +/// # }); +/// ``` +/// +/// For more info on how to work with query results please look at +/// [`QueryResult`](../struct.QueryResult.html) documentation. +#[derive(Debug)] +pub struct PooledConn { + pool: Pool, + conn: Option, +} + +impl Deref for PooledConn { + type Target = Conn; + + fn deref(&self) -> &Self::Target { + self.conn.as_ref().expect("deref after drop") + } +} + +impl Drop for PooledConn { + fn drop(&mut self) { + if let Some(mut conn) = self.conn.take() { + match conn.cleanup_for_pool() { + Ok(_) => { + let (protected, condvar) = self.pool.inner.protected(); + match protected.lock() { + Ok(mut protected) => { + protected.push_back(conn); + drop(protected); + condvar.notify_one(); + } + Err(_) => { + // everything is broken + self.pool.inner.decrease(); + } + } + } + Err(_) => { + // the connection is broken + self.pool.inner.decrease(); + } + } + } + } +} + +impl PooledConn { + /// Redirects to + /// [`Conn#start_transaction`](struct.Conn.html#method.start_transaction) + pub fn start_transaction(&mut self, tx_opts: TxOpts) -> Result> { + self.conn.as_mut().unwrap().start_transaction(tx_opts) + } + + /// Turns this connection into a binlog stream (see [`Conn::get_binlog_stream`]). + #[cfg(feature = "binlog")] + #[cfg_attr(docsrs, doc(cfg(feature = "binlog")))] + pub fn get_binlog_stream( + mut self, + request: crate::BinlogRequest<'_>, + ) -> Result { + self.conn.take().unwrap().get_binlog_stream(request) + } + + /// Unwraps wrapped [`Conn`](struct.Conn.html). + pub fn unwrap(mut self) -> Conn { + self.conn.take().unwrap() + } + + fn pooled_start_transaction(mut self, tx_opts: TxOpts) -> Result> { + self.as_mut()._start_transaction(tx_opts)?; + Ok(Transaction::new(self.into())) + } + + /// A way to override default local infile handler for this pooled connection. Destructor will + /// restore original handler before returning connection to a pool. + /// See [`Conn::set_local_infile_handler`](struct.Conn.html#method.set_local_infile_handler). + pub fn set_local_infile_handler(&mut self, handler: Option) { + self.conn + .as_mut() + .unwrap() + .set_local_infile_handler(handler); + } + + /// Invokes `COM_CHANGE_USER` (see [`Conn::change_user`] docs). + pub fn change_user(&mut self) -> Result<()> { + self.conn + .as_mut() + .unwrap() + .change_user(ChangeUserOpts::default()) + } + + /// Turns on/off automatic connection reset upon return to a pool (see [`Opts::get_pool_opts`]). + /// + /// Initial value is taken from [`crate::PoolOpts::reset_connection`]. + pub fn reset_connection(&mut self, reset_connection: bool) { + if let Some(conn) = self.conn.as_mut() { + conn.0.reset_upon_return = reset_connection; + } + } +} + +impl AsRef for PooledConn { + fn as_ref(&self) -> &Conn { + self.conn.as_ref().unwrap() + } +} + +impl AsMut for PooledConn { + fn as_mut(&mut self) -> &mut Conn { + self.conn.as_mut().unwrap() + } +} + +impl Queryable for PooledConn { + fn query_iter>(&mut self, query: T) -> Result> { + self.conn.as_mut().unwrap().query_iter(query) + } + + fn prep>(&mut self, query: T) -> Result { + self.conn.as_mut().unwrap().prep(query) + } + + fn close(&mut self, stmt: Statement) -> Result<()> { + self.conn.as_mut().unwrap().close(stmt) + } + + fn exec_iter(&mut self, stmt: S, params: P) -> Result> + where + S: AsStatement, + P: Into, + { + self.conn.as_mut().unwrap().exec_iter(stmt, params) + } + + fn exec_batch(&mut self, stmt: S, params: I) -> Result<()> + where + Self: Sized, + S: AsStatement, + P: Into, + I: IntoIterator, + { + self.conn.as_mut().unwrap().exec_batch(stmt, params) + } +} + +#[cfg(test)] +#[allow(non_snake_case)] +mod test { + mod pool { + use std::{thread, time::Duration}; + + use crate::{ + from_value, prelude::*, test_misc::get_opts, DriverError, Error, OptsBuilder, Pool, + PoolConstraints, PoolOpts, TxOpts, Value, + }; + + #[test] + fn multiple_pools_should_work() { + let pool = Pool::new(get_opts()).unwrap(); + pool.get_conn() + .unwrap() + .exec_drop("DROP DATABASE IF EXISTS A", ()) + .unwrap(); + pool.get_conn() + .unwrap() + .exec_drop("CREATE DATABASE A", ()) + .unwrap(); + pool.get_conn() + .unwrap() + .exec_drop("DROP TABLE IF EXISTS A.a", ()) + .unwrap(); + pool.get_conn() + .unwrap() + .exec_drop("CREATE TABLE IF NOT EXISTS A.a (id INT)", ()) + .unwrap(); + pool.get_conn() + .unwrap() + .exec_drop("INSERT INTO A.a VALUES (1)", ()) + .unwrap(); + let opts = OptsBuilder::from_opts(get_opts()).db_name(Some("A")); + let pool2 = Pool::new(opts).unwrap(); + let count: u8 = pool2 + .get_conn() + .unwrap() + .exec_first("SELECT COUNT(*) FROM a", ()) + .unwrap() + .unwrap(); + assert_eq!(1, count); + pool.get_conn() + .unwrap() + .exec_drop("DROP DATABASE A", ()) + .unwrap(); + } + + struct A { + pool: Pool, + x: u32, + } + + impl A { + fn add(&mut self) { + self.x += 1; + } + } + + #[test] + fn should_fix_connectivity_errors_on_prepare() { + let pool = Pool::new(get_opts().pool_opts( + PoolOpts::default().with_constraints(PoolConstraints::new_const::<2, 2>()), + )) + .unwrap(); + let mut conn = pool.get_conn().unwrap(); + + let id: u32 = pool + .get_conn() + .unwrap() + .exec_first("SELECT CONNECTION_ID();", ()) + .unwrap() + .unwrap(); + + conn.query_drop(&*format!("KILL {}", id)).unwrap(); + thread::sleep(Duration::from_millis(250)); + pool.get_conn() + .unwrap() + .prep("SHOW FULL PROCESSLIST") + .unwrap(); + } + + #[test] + fn should_fix_connectivity_errors_on_prep_exec() { + let pool = Pool::new(get_opts().pool_opts( + PoolOpts::default().with_constraints(PoolConstraints::new_const::<2, 2>()), + )) + .unwrap(); + let mut conn = pool.get_conn().unwrap(); + + let id: u32 = pool + .get_conn() + .unwrap() + .exec_first("SELECT CONNECTION_ID();", ()) + .unwrap() + .unwrap(); + + conn.query_drop(&*format!("KILL {}", id)).unwrap(); + thread::sleep(Duration::from_millis(250)); + pool.get_conn() + .unwrap() + .exec_drop("SHOW FULL PROCESSLIST", ()) + .unwrap(); + } + #[test] + fn should_fix_connectivity_errors_on_start_transaction() { + let pool = Pool::new(get_opts().pool_opts( + PoolOpts::default().with_constraints(PoolConstraints::new_const::<2, 2>()), + )) + .unwrap(); + let mut conn = pool.get_conn().unwrap(); + + let id: u32 = pool + .get_conn() + .unwrap() + .exec_first("SELECT CONNECTION_ID();", ()) + .unwrap() + .unwrap(); + + conn.query_drop(&*format!("KILL {}", id)).unwrap(); + thread::sleep(Duration::from_millis(250)); + pool.start_transaction(TxOpts::default()).unwrap(); + } + #[test] + fn should_execute_queries_on_PooledConn() { + let pool = Pool::new(get_opts()).unwrap(); + let mut threads = Vec::new(); + for _ in 0usize..10 { + let pool = pool.clone(); + threads.push(thread::spawn(move || { + let conn = pool.get_conn(); + assert!(conn.is_ok()); + let mut conn = conn.unwrap(); + conn.query_drop("SELECT 1").unwrap(); + })); + } + for t in threads.into_iter() { + assert!(t.join().is_ok()); + } + } + #[test] + fn should_timeout_if_no_connections_available() { + let pool = Pool::new(get_opts().pool_opts( + PoolOpts::default().with_constraints(PoolConstraints::new_const::<0, 1>()), + )) + .unwrap(); + let conn1 = pool.try_get_conn(Duration::from_millis(357)).unwrap(); + let conn2 = pool.try_get_conn(Duration::from_millis(357)); + assert!(conn2.is_err()); + match conn2 { + Err(Error::DriverError(DriverError::Timeout)) => (), + _ => panic!("Timeout error expected"), + } + drop(conn1); + assert!(pool.try_get_conn(Duration::from_millis(357)).is_ok()); + } + + #[test] + fn should_be_none_if_pool_size_zero_zero() { + let pool_constraints = PoolConstraints::new(0, 0); + assert!(pool_constraints.is_none()); + } + + #[test] + #[should_panic] + fn should_panic_if_pool_size_zero_zero() { + PoolConstraints::new_const::<0, 0>(); + } + + #[test] + fn should_execute_statements_on_PooledConn() { + let pool = Pool::new(get_opts()).unwrap(); + let mut threads = Vec::new(); + for _ in 0usize..10 { + let pool = pool.clone(); + threads.push(thread::spawn(move || { + let mut conn = pool.get_conn().unwrap(); + let stmt = conn.prep("SELECT 1").unwrap(); + conn.exec_drop(&stmt, ()).unwrap(); + })); + } + for t in threads.into_iter() { + assert!(t.join().is_ok()); + } + + let pool = Pool::new(get_opts()).unwrap(); + let mut threads = Vec::new(); + for _ in 0usize..10 { + let pool = pool.clone(); + threads.push(thread::spawn(move || { + let mut conn = pool.get_conn().unwrap(); + conn.exec_drop("SELECT ?", (1,)).unwrap(); + })); + } + for t in threads.into_iter() { + assert!(t.join().is_ok()); + } + } + + #[test] + #[allow(unused_variables)] + fn should_start_transaction_on_Pool() { + let pool = Pool::new( + get_opts().pool_opts( + PoolOpts::default() + .with_constraints(PoolConstraints::new_const::<1, 10>()) + .with_reset_connection(false), + ), + ) + .unwrap(); + pool.get_conn() + .unwrap() + .query_drop("CREATE TEMPORARY TABLE mysql.tbl(a INT)") + .unwrap(); + pool.start_transaction(TxOpts::default()) + .and_then(|mut t| { + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(1)").unwrap(); + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(2)").unwrap(); + t.commit() + }) + .unwrap(); + assert_eq!( + pool.get_conn() + .unwrap() + .query_first::("SELECT COUNT(a) FROM mysql.tbl") + .unwrap() + .unwrap(), + 2_u8 + ); + pool.start_transaction(TxOpts::default()) + .and_then(|mut t| { + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(1)").unwrap(); + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(2)").unwrap(); + t.rollback() + }) + .unwrap(); + assert_eq!( + pool.get_conn() + .unwrap() + .query_first::("SELECT COUNT(a) FROM mysql.tbl") + .unwrap() + .unwrap(), + 2_u8 + ); + pool.start_transaction(TxOpts::default()) + .map(|mut t| { + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(1)").unwrap(); + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(2)").unwrap(); + }) + .unwrap(); + assert_eq!( + pool.get_conn() + .unwrap() + .query_first::("SELECT COUNT(a) FROM mysql.tbl") + .unwrap() + .unwrap(), + 2_u8 + ); + let mut a = A { pool, x: 0 }; + let transaction = a.pool.start_transaction(TxOpts::default()).unwrap(); + a.add(); + } + + #[test] + fn should_reuse_connections() -> crate::Result<()> { + let pool = Pool::new(get_opts().pool_opts( + PoolOpts::default().with_constraints(PoolConstraints::new_const::<1, 1>()), + ))?; + let mut conn = pool.get_conn()?; + + let server_version = conn.server_version(); + let connection_id = conn.connection_id(); + + for _ in 0..16 { + drop(conn); + conn = pool.get_conn()?; + println!("CONN connection_id={}", conn.connection_id()); + assert!(conn.connection_id() == connection_id || server_version < (5, 7, 2)); + } + + Ok(()) + } + + #[test] + fn should_start_transaction_on_PooledConn() { + let pool = Pool::new(get_opts()).unwrap(); + let mut conn = pool.get_conn().unwrap(); + conn.query_drop("CREATE TEMPORARY TABLE mysql.tbl(a INT)") + .unwrap(); + conn.start_transaction(TxOpts::default()) + .and_then(|mut t| { + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(1)").unwrap(); + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(2)").unwrap(); + t.commit() + }) + .unwrap(); + for x in conn.query_iter("SELECT COUNT(a) FROM mysql.tbl").unwrap() { + let mut x = x.unwrap(); + assert_eq!(from_value::(x.take(0).unwrap()), 2u8); + } + conn.start_transaction(TxOpts::default()) + .and_then(|mut t| { + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(1)").unwrap(); + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(2)").unwrap(); + t.rollback() + }) + .unwrap(); + for x in conn.query_iter("SELECT COUNT(a) FROM mysql.tbl").unwrap() { + let mut x = x.unwrap(); + assert_eq!(from_value::(x.take(0).unwrap()), 2u8); + } + conn.start_transaction(TxOpts::default()) + .map(|mut t| { + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(1)").unwrap(); + t.query_drop("INSERT INTO mysql.tbl(a) VALUES(2)").unwrap(); + }) + .unwrap(); + for x in conn.query_iter("SELECT COUNT(a) FROM mysql.tbl").unwrap() { + let mut x = x.unwrap(); + assert_eq!(from_value::(x.take(0).unwrap()), 2u8); + } + } + + #[test] + fn should_opt_out_of_connection_reset() { + let pool_opts = PoolOpts::new().with_constraints(PoolConstraints::new_const::<1, 1>()); + let opts = get_opts().pool_opts(pool_opts.clone()); + + let pool = Pool::new(opts.clone()).unwrap(); + + let mut conn = pool.get_conn().unwrap(); + assert_eq!( + conn.query_first::("SELECT @foo").unwrap(), + Some(Value::NULL) + ); + conn.query_drop("SET @foo = 'foo'").unwrap(); + assert_eq!( + conn.query_first::("SELECT @foo") + .unwrap() + .unwrap(), + "foo", + ); + drop(conn); + + conn = pool.get_conn().unwrap(); + assert_eq!( + conn.query_first::("SELECT @foo").unwrap(), + Some(Value::NULL) + ); + conn.query_drop("SET @foo = 'foo'").unwrap(); + conn.reset_connection(false); + drop(conn); + + conn = pool.get_conn().unwrap(); + assert_eq!( + conn.query_first::("SELECT @foo") + .unwrap() + .unwrap(), + "foo", + ); + drop(conn); + + let pool = Pool::new(opts.pool_opts(pool_opts.with_reset_connection(false))).unwrap(); + conn = pool.get_conn().unwrap(); + conn.query_drop("SET @foo = 'foo'").unwrap(); + drop(conn); + conn = pool.get_conn().unwrap(); + assert_eq!( + conn.query_first::("SELECT @foo") + .unwrap() + .unwrap(), + "foo", + ); + drop(conn); + } + + #[cfg(feature = "nightly")] + mod bench { + use test; + + use std::thread; + + use crate::{prelude::*, test_misc::get_opts, Pool}; + + #[bench] + fn many_prepexecs(bencher: &mut test::Bencher) { + let pool = Pool::new(get_opts()).unwrap(); + bencher.iter(|| { + "SELECT 1".with(()).run(&pool).unwrap(); + }); + } + + #[bench] + fn many_prepares_threaded(bencher: &mut test::Bencher) { + let pool = Pool::new(get_opts()).unwrap(); + bencher.iter(|| { + let mut threads = Vec::new(); + for _ in 0..4 { + let pool = pool.clone(); + threads.push(thread::spawn(move || { + for _ in 0..250 { + test::black_box( + "SELECT 1, 'hello world', 123.321, ?, ?, ?" + .with(("hello", "world", 65536)) + .run(&pool) + .unwrap(), + ); + } + })); + } + for t in threads { + t.join().unwrap(); + } + }); + } + + #[bench] + fn many_prepares_threaded_no_cache(bencher: &mut test::Bencher) { + let mut pool = Pool::new(get_opts()).unwrap(); + pool.use_cache(false); + bencher.iter(|| { + let mut threads = Vec::new(); + for _ in 0..4 { + let pool = pool.clone(); + threads.push(thread::spawn(move || { + for _ in 0..250 { + test::black_box( + "SELECT 1, 'hello world', 123.321, ?, ?, ?" + .with(("hello", "world", 65536)) + .run(&pool) + .unwrap(), + ); + } + })); + } + for t in threads { + t.join().unwrap(); + } + }); + } + } + } +} diff --git a/vendor/mysql-28.0.0/src/conn/query.rs b/vendor/mysql-28.0.0/src/conn/query.rs new file mode 100644 index 0000000000..028cdd8651 --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/query.rs @@ -0,0 +1,398 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use mysql_common::row::convert::FromRowError; + +use std::{convert::TryInto, result::Result as StdResult}; + +use crate::{ + conn::{queryable::AsStatement, ConnMut}, + from_row, from_row_opt, + prelude::FromRow, + Binary, Error, Params, QueryResult, Result, Text, +}; + +/// MySql text query. +/// +/// This trait covers the set of `query*` methods on the `Queryable` trait. +/// Please see the corresponding section of the crate level docs for details. +/// +/// Example: +/// +/// ```rust +/// # mysql::doctest_wrapper!(__result, { +/// use mysql::*; +/// use mysql::prelude::*; +/// let pool = Pool::new(get_opts())?; +/// +/// let num: Option = "SELECT 42".first(&pool)?; +/// +/// assert_eq!(num, Some(42)); +/// # }); +/// ``` +pub trait TextQuery: Sized { + /// This methods corresponds to `Queryable::query_iter`. + fn run<'a, 'b, 'c, C>(self, conn: C) -> Result> + where + C: TryInto>, + Error: From<>>::Error>; + + /// This methods corresponds to `Queryable::query_first`. + fn first<'a, 'b, 'c: 'b, T, C>(self, conn: C) -> Result> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + { + self.run(conn)? + .next() + .map(|row| row.map(from_row)) + .transpose() + } + + /// Same as [`TextQuery::first`] but useful when you not sure what your schema is. + fn first_opt<'a, 'b, 'c: 'b, T, C>(self, conn: C) -> Result>> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + { + self.run(conn)? + .next() + .map(|row| row.map(from_row_opt)) + .transpose() + } + + /// This methods corresponds to `Queryable::query`. + fn fetch<'a, 'b, 'c: 'b, T, C>(self, conn: C) -> Result> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + { + self.run(conn)?.map(|rrow| rrow.map(from_row)).collect() + } + + /// Same as [`TextQuery::fetch`] but useful when you not sure what your schema is. + fn fetch_opt<'a, 'b, 'c: 'b, T, C>(self, conn: C) -> Result>> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + { + self.run(conn)?.map(|rrow| rrow.map(from_row_opt)).collect() + } + + /// This methods corresponds to `Queryable::query_fold`. + fn fold<'a, 'b, 'c: 'b, T, U, F, C>(self, conn: C, mut init: U, mut next: F) -> Result + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + F: FnMut(U, T) -> U, + { + for row in self.run(conn)? { + init = next(init, from_row(row?)); + } + + Ok(init) + } + + /// Same as [`TextQuery::fold`] but useful when you not sure what your schema is. + fn fold_opt<'a, 'b, 'c: 'b, T, U, F, C>(self, conn: C, mut init: U, mut next: F) -> Result + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + F: FnMut(U, StdResult) -> U, + { + for row in self.run(conn)? { + init = next(init, from_row_opt(row?)); + } + + Ok(init) + } + + /// This methods corresponds to `Queryable::query_map`. + fn map<'a, 'b, 'c: 'b, T, U, F, C>(self, conn: C, mut map: F) -> Result> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + F: FnMut(T) -> U, + { + self.fold(conn, Vec::new(), |mut acc, row: T| { + acc.push(map(row)); + acc + }) + } + + /// Same as [`TextQuery::map`] but useful when you not sure what your schema is. + fn map_opt<'a, 'b, 'c: 'b, T, U, F, C>(self, conn: C, mut map: F) -> Result> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + F: FnMut(StdResult) -> U, + { + self.fold_opt( + conn, + Vec::new(), + |mut acc, row: StdResult| { + acc.push(map(row)); + acc + }, + ) + } +} + +impl> TextQuery for Q { + fn run<'a, 'b, 'c, C>(self, conn: C) -> Result> + where + C: TryInto>, + Error: From<>>::Error>, + { + let mut conn = conn.try_into()?; + let meta = conn._query(self.as_ref())?; + Ok(QueryResult::new(conn, meta)) + } +} + +/// Representation of a prepared statement query. +/// +/// See `BinQuery` for details. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QueryWithParams { + pub query: Q, + pub params: P, +} + +/// Helper, that constructs `QueryWithParams`. +pub trait WithParams: Sized { + fn with

(self, params: P) -> QueryWithParams; +} + +impl> WithParams for T { + fn with

(self, params: P) -> QueryWithParams { + QueryWithParams { + query: self, + params, + } + } +} + +/// MySql prepared statement query. +/// +/// This trait covers the set of `exec*` methods on the `Queryable` trait. +/// Please see the corresponding section of the crate level docs for details. +/// +/// Example: +/// +/// ```rust +/// # mysql::doctest_wrapper!(__result, { +/// use mysql::*; +/// use mysql::prelude::*; +/// let pool = Pool::new(get_opts())?; +/// +/// let num: Option = "SELECT ?" +/// .with((42,)) +/// .first(&pool)?; +/// +/// assert_eq!(num, Some(42)); +/// # }); +/// ``` +pub trait BinQuery: Sized { + /// This methods corresponds to `Queryable::exec_iter`. + fn run<'a, 'b, 'c, C>(self, conn: C) -> Result> + where + C: TryInto>, + Error: From<>>::Error>; + + /// This methods corresponds to `Queryable::exec_first`. + fn first<'a, 'b, 'c: 'b, T, C>(self, conn: C) -> Result> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + { + self.run(conn)? + .next() + .map(|row| row.map(from_row)) + .transpose() + } + + /// Same as [`BinQuery::first`] but useful when you not sure what your schema is. + fn first_opt<'a, 'b, 'c: 'b, T, C>(self, conn: C) -> Result>> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + { + self.run(conn)? + .next() + .map(|row| row.map(from_row_opt)) + .transpose() + } + + /// This methods corresponds to `Queryable::exec`. + fn fetch<'a, 'b, 'c: 'b, T, C>(self, conn: C) -> Result> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + { + self.run(conn)?.map(|rrow| rrow.map(from_row)).collect() + } + + /// Same as [`BinQuery::fetch`] but useful when you not sure what your schema is. + fn fetch_opt<'a, 'b, 'c: 'b, T, C>(self, conn: C) -> Result>> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + { + self.run(conn)?.map(|rrow| rrow.map(from_row_opt)).collect() + } + + /// This methods corresponds to `Queryable::exec_fold`. + fn fold<'a, 'b, 'c: 'b, T, U, F, C>(self, conn: C, mut init: U, mut next: F) -> Result + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + F: FnMut(U, T) -> U, + { + for row in self.run(conn)? { + init = next(init, from_row(row?)); + } + + Ok(init) + } + + /// Same as [`BinQuery::fold`] but useful when you not sure what your schema is. + fn fold_opt<'a, 'b, 'c: 'b, T, U, F, C>(self, conn: C, mut init: U, mut next: F) -> Result + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + F: FnMut(U, StdResult) -> U, + { + for row in self.run(conn)? { + init = next(init, from_row_opt(row?)); + } + + Ok(init) + } + + /// This methods corresponds to `Queryable::exec_map`. + fn map<'a, 'b, 'c: 'b, T, U, F, C>(self, conn: C, mut map: F) -> Result> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + F: FnMut(T) -> U, + { + self.fold(conn, Vec::new(), |mut acc, row: T| { + acc.push(map(row)); + acc + }) + } + + /// Same as [`BinQuery::map`] but useful when you not sure what your schema is. + fn map_opt<'a, 'b, 'c: 'b, T, U, F, C>(self, conn: C, mut map: F) -> Result> + where + C: TryInto>, + Error: From<>>::Error>, + T: FromRow, + F: FnMut(StdResult) -> U, + { + self.fold_opt( + conn, + Vec::new(), + |mut acc, row: StdResult| { + acc.push(map(row)); + acc + }, + ) + } +} + +impl BinQuery for QueryWithParams +where + Q: AsStatement, + P: Into, +{ + fn run<'a, 'b, 'c, C>(self, conn: C) -> Result> + where + C: TryInto>, + Error: From<>>::Error>, + { + let mut conn = conn.try_into()?; + let statement = self.query.as_statement(&mut *conn)?; + let info = conn._execute(&statement, self.params.into())?; + let meta = info.into_statement_meta(&conn, &statement); + Ok(QueryResult::new(conn, meta)) + } +} + +/// Helper trait for batch statement execution. +/// +/// This trait covers the `Queryable::exec_batch` method. +/// Please see the corresponding section of the crate level docs for details. +/// +/// Example: +/// +/// ```rust +/// # mysql::doctest_wrapper!(__result, { +/// use mysql::*; +/// use mysql::prelude::*; +/// let pool = Pool::new(get_opts())?; +/// +/// // This will prepare `DO ?` and execute `DO 0`, `DO 1`, `DO 2` and so on. +/// "DO ?" +/// .with((0..10).map(|x| (x,))) +/// .batch(&pool)?; +/// # }); +/// ``` +pub trait BatchQuery { + fn batch<'a, 'b, 'c: 'b, C>(self, conn: C) -> Result<()> + where + C: TryInto>, + Error: From<>>::Error>; +} + +impl BatchQuery for QueryWithParams +where + Q: AsStatement, + I: IntoIterator, + P: Into, +{ + /// This methods corresponds to `Queryable::exec_batch`. + fn batch<'a, 'b, 'c: 'b, C>(self, conn: C) -> Result<()> + where + C: TryInto>, + Error: From<>>::Error>, + { + let mut conn = conn.try_into()?; + let statement = self.query.as_statement(&mut *conn)?; + + for params in self.params { + let params = params.into(); + let info = conn._execute(&statement, params)?; + let meta = info.into_statement_meta(&conn, &statement); + let mut query_result = QueryResult::::new((&mut *conn).into(), meta); + while let Some(result_set) = query_result.iter() { + for row in result_set { + row?; + } + } + } + + Ok(()) + } +} diff --git a/vendor/mysql-28.0.0/src/conn/query_result.rs b/vendor/mysql-28.0.0/src/conn/query_result.rs new file mode 100644 index 0000000000..77691355df --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/query_result.rs @@ -0,0 +1,403 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +pub use mysql_common::proto::{Binary, Text}; + +use mysql_common::{io::ParseBuf, packets::OkPacket, row::RowDeserializer, value::ServerSide}; + +use std::{borrow::Cow, marker::PhantomData, sync::Arc}; + +use crate::{conn::ConnMut, Column, Conn, Error, Result, Row}; + +/// Result set kind. +pub trait Protocol: 'static + Send + Sync { + fn next(conn: &mut Conn, columns: Arc<[Column]>) -> Result>; +} + +impl Protocol for Text { + fn next(conn: &mut Conn, columns: Arc<[Column]>) -> Result> { + match conn.next_row_packet()? { + Some(pld) => { + let row = ParseBuf(&pld).parse::>(columns)?; + Ok(Some(row.into())) + } + None => Ok(None), + } + } +} + +impl Protocol for Binary { + fn next(conn: &mut Conn, columns: Arc<[Column]>) -> Result> { + match conn.next_row_packet()? { + Some(pld) => { + let row = ParseBuf(&pld).parse::>(columns)?; + Ok(Some(row.into())) + } + None => Ok(None), + } + } +} + +/// State of a result set iterator. +#[derive(Debug)] +enum SetIteratorState { + /// Iterator is in a non-empty set. + InSet(Arc<[Column]>), + /// Iterator is in an empty set. + InEmptySet(OkPacket<'static>), + /// Iterator is in an errored result set. + Errored(Error), + /// Next result set isn't handled. + OnBoundary, + /// No more result sets. + Done, +} + +impl SetIteratorState { + fn ok_packet(&self) -> Option<&OkPacket<'_>> { + if let Self::InEmptySet(ref ok) = self { + Some(ok) + } else { + None + } + } + + fn columns(&self) -> Option<&Arc<[Column]>> { + if let Self::InSet(ref cols) = self { + Some(cols) + } else { + None + } + } +} + +impl From> for SetIteratorState { + fn from(columns: Arc<[Column]>) -> Self { + Self::InSet(columns) + } +} + +impl From> for SetIteratorState { + fn from(ok_packet: OkPacket<'static>) -> Self { + Self::InEmptySet(ok_packet) + } +} + +impl From for SetIteratorState { + fn from(err: Error) -> Self { + Self::Errored(err) + } +} + +impl From for SetIteratorState { + fn from(value: ResultSetMeta) -> Self { + match value { + ResultSetMeta::Empty(ok_packet) => Self::from(ok_packet), + ResultSetMeta::NonEmptyWithMeta(column) => Self::from(column), + } + } +} + +#[derive(Debug)] +pub(crate) enum ResultSetMeta { + Empty(OkPacket<'static>), + NonEmptyWithMeta(Arc<[Column]>), +} + +/// Response to a query or statement execution. +/// +/// It is an iterator: +/// * over result sets (via `Self::current_set`) +/// * over rows of a current result set (via `Iterator` impl) +#[derive(Debug)] +pub struct QueryResult<'c, 't, 'tc, T: crate::prelude::Protocol> { + conn: ConnMut<'c, 't, 'tc>, + state: SetIteratorState, + set_index: usize, + protocol: PhantomData, +} + +impl<'c, 't, 'tc, T: crate::prelude::Protocol> QueryResult<'c, 't, 'tc, T> { + fn from_state( + conn: ConnMut<'c, 't, 'tc>, + state: SetIteratorState, + ) -> QueryResult<'c, 't, 'tc, T> { + QueryResult { + conn, + state, + set_index: 0, + protocol: PhantomData, + } + } + + pub(crate) fn new( + conn: ConnMut<'c, 't, 'tc>, + meta: ResultSetMeta, + ) -> QueryResult<'c, 't, 'tc, T> { + Self::from_state(conn, meta.into()) + } + + /// Updates state with the next result set, if any. + /// + /// Returns `false` if there is no next result set. + /// + /// **Requires:** `self.state == OnBoundary` + fn handle_next(&mut self) { + debug_assert!( + matches!(self.state, SetIteratorState::OnBoundary), + "self.state != OnBoundary" + ); + + if self.conn.more_results_exists() { + match self.conn.handle_result_set() { + Ok(info) => self.state = info.into_query_meta().into(), + Err(err) => self.state = err.into(), + } + self.set_index += 1; + } else { + self.state = SetIteratorState::Done; + } + } + + /// Returns an iterator over the current result set. + #[deprecated = "Please use QueryResult::iter"] + pub fn next_set<'d>(&'d mut self) -> Option> { + self.iter() + } + + /// Returns an iterator over the current result set. + /// + /// The returned iterator will be consumed either by the caller + /// or implicitly by the `ResultSet::drop`. This operation + /// will advance `self` to the next result set (if any). + /// + /// The following code describes the behavior: + /// + /// ```rust + /// # mysql::doctest_wrapper!(__result, { + /// # use mysql::*; + /// # use mysql::prelude::*; + /// # let pool = Pool::new(get_opts())?; + /// # let mut conn = pool.get_conn()?; + /// # conn.query_drop("CREATE TEMPORARY TABLE mysql.tbl(id INT NOT NULL PRIMARY KEY)")?; + /// + /// let mut query_result = conn.query_iter("\ + /// INSERT INTO mysql.tbl (id) VALUES (3, 4);\ + /// SELECT * FROM mysql.tbl; + /// UPDATE mysql.tbl SET id = id + 1;")?; + /// + /// // query_result is on the first result set at the moment + /// { + /// assert_eq!(query_result.affected_rows(), 2); + /// assert_eq!(query_result.last_insert_id(), Some(4)); + /// + /// let first_result_set = query_result.iter().unwrap(); + /// assert_eq!(first_result_set.affected_rows(), 2); + /// assert_eq!(first_result_set.last_insert_id(), Some(4)); + /// } + /// + /// // the first result set is now dropped, so query_result is on the second result set + /// { + /// assert_eq!(query_result.affected_rows(), 0); + /// assert_eq!(query_result.last_insert_id(), None); + /// + /// let mut second_result_set = query_result.iter().unwrap(); + /// + /// let first_row = second_result_set.next().unwrap().unwrap(); + /// assert_eq!(from_row::(first_row), 3_u8); + /// let second_row = second_result_set.next().unwrap().unwrap(); + /// assert_eq!(from_row::(second_row), 4_u8); + /// + /// assert!(second_result_set.next().is_none()); + /// + /// // second_result_set is consumed but still represents the second result set + /// assert_eq!(second_result_set.affected_rows(), 0); + /// } + /// + /// // the second result set is now dropped, so query_result is on the third result set + /// assert_eq!(query_result.affected_rows(), 2); + /// + /// // QueryResult::drop simply does the following: + /// while query_result.iter().is_some() {} + /// # }); + /// ``` + pub fn iter<'d>(&'d mut self) -> Option> { + use SetIteratorState::*; + + if let OnBoundary | Done = &self.state { + debug_assert!( + !self.conn.more_results_exists(), + "the next state must be handled by the Iterator::next" + ); + + None + } else { + Some(ResultSet { + set_index: self.set_index, + inner: self, + }) + } + } + + /// Returns the number of affected rows for the current result set. + pub fn affected_rows(&self) -> u64 { + self.state + .ok_packet() + .map(|ok| ok.affected_rows()) + .unwrap_or_default() + } + + /// Returns the last insert id for the current result set. + pub fn last_insert_id(&self) -> Option { + self.state + .ok_packet() + .map(|ok| ok.last_insert_id()) + .unwrap_or_default() + } + + /// Returns the warnings count for the current result set. + pub fn warnings(&self) -> u16 { + self.state + .ok_packet() + .map(|ok| ok.warnings()) + .unwrap_or_default() + } + + /// [Info] for the current result set. + /// + /// Will be empty if not defined. + /// + /// [Info]: http://dev.mysql.com/doc/internals/en/packet-OK_Packet.html + pub fn info_ref(&self) -> &[u8] { + self.state + .ok_packet() + .and_then(|ok| ok.info_ref()) + .unwrap_or_default() + } + + /// [Info] for the current result set. + /// + /// Will be empty if not defined. + /// + /// [Info]: http://dev.mysql.com/doc/internals/en/packet-OK_Packet.html + pub fn info_str(&self) -> Cow<'_, str> { + self.state + .ok_packet() + .and_then(|ok| ok.info_str()) + .unwrap_or_else(|| "".into()) + } + + /// Returns columns of the current result rest. + pub fn columns(&self) -> SetColumns<'_> { + SetColumns { + inner: self.state.columns(), + } + } +} + +impl<'c, 't, 'tc, T: crate::prelude::Protocol> Drop for QueryResult<'c, 't, 'tc, T> { + fn drop(&mut self) { + while self.iter().is_some() {} + } +} + +#[derive(Debug)] +pub struct ResultSet<'a, 'b, 'c, 'd, T: crate::prelude::Protocol> { + set_index: usize, + inner: &'d mut QueryResult<'a, 'b, 'c, T>, +} + +impl<'a, 'b, 'c, T: crate::prelude::Protocol> std::ops::Deref for ResultSet<'a, 'b, 'c, '_, T> { + type Target = QueryResult<'a, 'b, 'c, T>; + + fn deref(&self) -> &Self::Target { + &*self.inner + } +} + +impl Iterator for ResultSet<'_, '_, '_, '_, T> { + type Item = Result; + + fn next(&mut self) -> Option { + if self.set_index == self.inner.set_index { + self.inner.next() + } else { + None + } + } +} + +impl Iterator for QueryResult<'_, '_, '_, T> { + type Item = Result; + + fn next(&mut self) -> Option { + use SetIteratorState::*; + + let state = std::mem::replace(&mut self.state, OnBoundary); + + match state { + InSet(cols) => match T::next(&mut self.conn, cols.clone()) { + Ok(Some(row)) => { + self.state = InSet(cols); + Some(Ok(row)) + } + Ok(None) => { + self.handle_next(); + None + } + Err(e) => { + self.handle_next(); + Some(Err(e)) + } + }, + InEmptySet(_) => { + self.handle_next(); + None + } + Errored(err) => { + self.handle_next(); + Some(Err(err)) + } + OnBoundary => None, + Done => { + self.state = Done; + None + } + } + } +} + +impl Drop for ResultSet<'_, '_, '_, '_, T> { + fn drop(&mut self) { + while self.next().is_some() {} + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SetColumns<'a> { + inner: Option<&'a Arc<[Column]>>, +} + +impl<'a> SetColumns<'a> { + /// Returns an index of a column by its name. + pub fn column_index>(&self, name: U) -> Option { + let name = name.as_ref().as_bytes(); + self.inner + .as_ref() + .and_then(|cols| cols.iter().position(|col| col.name_ref() == name)) + } +} + +impl AsRef<[Column]> for SetColumns<'_> { + fn as_ref(&self) -> &[Column] { + self.inner + .as_ref() + .map(|cols| &(*cols)[..]) + .unwrap_or(&[][..]) + } +} diff --git a/vendor/mysql-28.0.0/src/conn/queryable.rs b/vendor/mysql-28.0.0/src/conn/queryable.rs new file mode 100644 index 0000000000..832ef8a52b --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/queryable.rs @@ -0,0 +1,271 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use mysql_common::row::convert::FromRowError; + +use std::{borrow::Cow, result::Result as StdResult}; + +use crate::{ + conn::query_result::{Binary, Text}, + from_row, from_row_opt, + prelude::FromRow, + Params, QueryResult, Result, Statement, +}; + +/// Something, that eventually is a `Statement` in the context of a `T: Queryable`. +pub trait AsStatement { + /// Make a statement out of `Self`. + fn as_statement(&self, queryable: &mut Q) -> Result>; +} + +/// Queryable object. +pub trait Queryable { + /// Performs text query. + fn query_iter>(&mut self, query: Q) -> Result>; + + /// Performs text query and collects the first result set. + fn query(&mut self, query: Q) -> Result> + where + Q: AsRef, + T: FromRow, + { + self.query_map(query, from_row) + } + + /// Same as [`Queryable::query`] but useful when you not sure what your schema is. + fn query_opt(&mut self, query: Q) -> Result>> + where + Q: AsRef, + T: FromRow, + { + self.query_map(query, from_row_opt) + } + + /// Performs text query and returns the first row of the first result set. + fn query_first(&mut self, query: Q) -> Result> + where + Q: AsRef, + T: FromRow, + { + self.query_iter(query)? + .next() + .map(|row| row.map(from_row)) + .transpose() + } + + /// Same as [`Queryable::query_first`] but useful when you not sure what your schema is. + fn query_first_opt(&mut self, query: Q) -> Result>> + where + Q: AsRef, + T: FromRow, + { + self.query_iter(query)? + .next() + .map(|row| row.map(from_row_opt)) + .transpose() + } + + /// Performs text query and maps each row of the first result set. + fn query_map(&mut self, query: Q, mut f: F) -> Result> + where + Q: AsRef, + T: FromRow, + F: FnMut(T) -> U, + { + self.query_fold(query, Vec::new(), |mut acc, row| { + acc.push(f(row)); + acc + }) + } + + /// Same as [`Queryable::query_map`] but useful when you not sure what your schema is. + fn query_map_opt(&mut self, query: Q, mut f: F) -> Result> + where + Q: AsRef, + T: FromRow, + F: FnMut(StdResult) -> U, + { + self.query_fold_opt(query, Vec::new(), |mut acc, row| { + acc.push(f(row)); + acc + }) + } + + /// Performs text query and folds the first result set to a single value. + fn query_fold(&mut self, query: Q, init: U, mut f: F) -> Result + where + Q: AsRef, + T: FromRow, + F: FnMut(U, T) -> U, + { + self.query_iter(query)? + .map(|row| row.map(from_row::)) + .try_fold(init, |acc, row: Result| row.map(|row| f(acc, row))) + } + + /// Same as [`Queryable::query_fold`] but useful when you not sure what your schema is. + fn query_fold_opt(&mut self, query: Q, init: U, mut f: F) -> Result + where + Q: AsRef, + T: FromRow, + F: FnMut(U, StdResult) -> U, + { + self.query_iter(query)? + .map(|row| row.map(from_row_opt::)) + .try_fold(init, |acc, row: Result>| { + row.map(|row| f(acc, row)) + }) + } + + /// Performs text query and drops the query result. + fn query_drop(&mut self, query: Q) -> Result<()> + where + Q: AsRef, + { + self.query_iter(query).map(drop) + } + + /// Prepares the given `query` as a prepared statement. + fn prep>(&mut self, query: Q) -> Result; + + /// This function will close the given statement on the server side. + fn close(&mut self, stmt: Statement) -> Result<()>; + + /// Executes the given `stmt` with the given `params`. + fn exec_iter(&mut self, stmt: S, params: P) -> Result> + where + S: AsStatement, + P: Into; + + /// Prepares the given statement, and executes it with each item in the given params iterator. + /// + /// # Note + /// + /// It will use `COM_STMT_BULK_EXECUTE` for MariaDb >= v10.2. Practically + /// this means that the function will error with code 1295 (`ER_UNSUPPORTED_PS`) + /// for _non-bulk execution safe_ operations, namely for all operations + /// except UPDATE, multi-UPDATE, INSERT, DELETE and REPLACE. Consider not + /// using exec batch for operations outside of this list. + fn exec_batch(&mut self, stmt: S, params: I) -> Result<()> + where + Self: Sized, + S: AsStatement, + P: Into, + I: IntoIterator; + + /// Executes the given `stmt` and collects the first result set. + fn exec(&mut self, stmt: S, params: P) -> Result> + where + S: AsStatement, + P: Into, + T: FromRow, + { + self.exec_map(stmt, params, from_row) + } + + /// Same as [`Queryable::exec`] but useful when you not sure what your schema is. + fn exec_opt(&mut self, stmt: S, params: P) -> Result>> + where + S: AsStatement, + P: Into, + T: FromRow, + { + self.exec_map(stmt, params, from_row_opt) + } + + /// Executes the given `stmt` and returns the first row of the first result set. + fn exec_first(&mut self, stmt: S, params: P) -> Result> + where + S: AsStatement, + P: Into, + T: FromRow, + { + self.exec_iter(stmt, params)? + .next() + .map(|row| row.map(crate::from_row)) + .transpose() + } + + /// Same as [`Queryable::exec_first`] but useful when you not sure what your schema is. + fn exec_first_opt( + &mut self, + stmt: S, + params: P, + ) -> Result>> + where + S: AsStatement, + P: Into, + T: FromRow, + { + self.exec_iter(stmt, params)? + .next() + .map(|row| row.map(from_row_opt)) + .transpose() + } + + /// Executes the given `stmt` and maps each row of the first result set. + fn exec_map(&mut self, stmt: S, params: P, mut f: F) -> Result> + where + S: AsStatement, + P: Into, + T: FromRow, + F: FnMut(T) -> U, + { + self.exec_fold(stmt, params, Vec::new(), |mut acc, row| { + acc.push(f(row)); + acc + }) + } + + /// Same as [`Queryable::exec_map`] but useful when you not sure what your schema is. + fn exec_map_opt(&mut self, stmt: S, params: P, mut f: F) -> Result> + where + S: AsStatement, + P: Into, + T: FromRow, + F: FnMut(StdResult) -> U, + { + self.exec_fold_opt(stmt, params, Vec::new(), |mut acc, row| { + acc.push(f(row)); + acc + }) + } + + /// Executes the given `stmt` and folds the first result set to a single value. + fn exec_fold(&mut self, stmt: S, params: P, init: U, mut f: F) -> Result + where + S: AsStatement, + P: Into, + T: FromRow, + F: FnMut(U, T) -> U, + { + let mut result = self.exec_iter(stmt, params)?; + result.try_fold(init, |init, row| row.map(|row| f(init, from_row(row)))) + } + + /// Same as [`Queryable::exec_fold`] but useful when you not sure what your schema is. + fn exec_fold_opt(&mut self, stmt: S, params: P, init: U, mut f: F) -> Result + where + S: AsStatement, + P: Into, + T: FromRow, + F: FnMut(U, StdResult) -> U, + { + let mut result = self.exec_iter(stmt, params)?; + result.try_fold(init, |init, row| row.map(|row| f(init, from_row_opt(row)))) + } + + /// Executes the given `stmt` and drops the result. + fn exec_drop(&mut self, stmt: S, params: P) -> Result<()> + where + S: AsStatement, + P: Into, + { + self.exec_iter(stmt, params).map(drop) + } +} diff --git a/vendor/mysql-28.0.0/src/conn/stmt.rs b/vendor/mysql-28.0.0/src/conn/stmt.rs new file mode 100644 index 0000000000..3fe9504c11 --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/stmt.rs @@ -0,0 +1,237 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use crossbeam_utils::atomic::AtomicCell; +use mysql_common::{io::ParseBuf, packets::StmtPacket, proto::MyDeserialize}; + +use std::{borrow::Cow, fmt, io, ptr::NonNull, sync::Arc}; + +use crate::{prelude::*, Column, Result}; + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct InnerStmt { + columns: Option>, + /// This cached value overrides the column metadata stored in the `inner` field. + /// + /// See MARIADB_CLIENT_CACHE_METADATA capability. + columns_cache: ColumnCache, + params: Option>, + stmt_packet: StmtPacket, + connection_id: u32, +} + +impl<'de> MyDeserialize<'de> for InnerStmt { + const SIZE: Option = StmtPacket::SIZE; + type Ctx = u32; + + fn deserialize(connection_id: Self::Ctx, buf: &mut ParseBuf<'de>) -> io::Result { + let stmt_packet = buf.parse(())?; + + Ok(InnerStmt { + columns: None, + columns_cache: ColumnCache::new(), + params: None, + stmt_packet, + connection_id, + }) + } +} + +impl InnerStmt { + pub fn with_params(mut self, params: Option>) -> Self { + self.params = params.map(Into::into); + self + } + + pub fn with_columns(mut self, columns: Option>) -> Self { + self.columns = columns.map(|x| x.into()); + self + } + + pub fn columns(&self) -> Arc<[Column]> { + self.columns_cache + .get_columns() + .or_else(|| self.columns.clone()) + .unwrap_or_default() + } + + pub fn update_columns_metadata(&self, columns: Vec) { + self.columns_cache.set_columns(columns); + } + + pub fn params(&self) -> &[Column] { + self.params.as_ref().map(AsRef::as_ref).unwrap_or(&[]) + } + + pub fn id(&self) -> u32 { + self.stmt_packet.statement_id() + } + + pub const fn connection_id(&self) -> u32 { + self.connection_id + } + + pub fn num_params(&self) -> u16 { + self.stmt_packet.num_params() + } + + pub fn num_columns(&self) -> u16 { + self.stmt_packet.num_columns() + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Statement { + pub(crate) inner: Arc, + pub(crate) named_params: Option>>, +} + +impl Statement { + pub(crate) fn new(inner: Arc, named_params: Option>>) -> Self { + Self { + inner, + named_params, + } + } + + pub fn columns(&self) -> Arc<[Column]> { + self.inner.columns() + } + + /// Overrides columns metadata for this statement. + /// + /// See MARIADB_CLIENT_CACHE_METADATA capability. + pub(crate) fn update_columns_metadata(&self, columns: Vec) { + self.inner.update_columns_metadata(columns); + } + + pub fn params(&self) -> &[Column] { + self.inner.params() + } + + pub fn id(&self) -> u32 { + self.inner.id() + } + + pub fn connection_id(&self) -> u32 { + self.inner.connection_id() + } + + pub fn num_params(&self) -> u16 { + self.inner.num_params() + } + + pub fn num_columns(&self) -> u16 { + self.inner.num_columns() + } +} + +impl AsStatement for Statement { + fn as_statement(&self, _queryable: &mut Q) -> Result> { + Ok(Cow::Borrowed(self)) + } +} + +impl AsStatement for &'_ Statement { + fn as_statement(&self, _queryable: &mut Q) -> Result> { + Ok(Cow::Borrowed(self)) + } +} + +impl> AsStatement for T { + fn as_statement(&self, queryable: &mut Q) -> Result> { + let statement = queryable.prep(self.as_ref())?; + Ok(Cow::Owned(statement)) + } +} + +/// This is to make raw Arc pointer Send and Sync +/// +/// This splits fat `*const [Column]` pointer to its components +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(transparent)] +struct ColumnsArcPtr((NonNull, usize)); + +impl ColumnsArcPtr { + fn from_arc(arc: Arc<[Column]>) -> Self { + let len = arc.len(); + let ptr = Arc::into_raw(arc); + // SAFETY: the `Arc` structure itself contains NonNull so this is either safe + // or someone created a broken `Arc` using unsafe code. + let ptr = unsafe { NonNull::new_unchecked(ptr as *const Column as *mut Column) }; + Self((ptr, len)) + } + + fn to_arc(self) -> Arc<[Column]> { + let columns = self.into_arc(); + let clone = columns.clone(); + // ignore the pointer because it is already stored in self + let _ = Arc::into_raw(columns); + clone + } + + fn into_arc(self) -> Arc<[Column]> { + let fat_pointer = NonNull::slice_from_raw_parts(self.0 .0, self.0 .1); + // SAFETY: non-null pointer always points to a valid Arc + unsafe { Arc::from_raw(fat_pointer.as_ptr()) } + } +} + +unsafe impl Send for ColumnsArcPtr {} +unsafe impl Sync for ColumnsArcPtr {} + +struct ColumnCache { + columns: AtomicCell>, +} + +impl ColumnCache { + fn new() -> Self { + Self { + columns: AtomicCell::new(None), + } + } + + fn get_columns(&self) -> Option> { + self.columns.load().map(|x| x.to_arc()) + } + + fn set_columns(&self, new_columns: Vec) { + let new_columns: Arc<[Column]> = new_columns.into(); + let new_ptr = ColumnsArcPtr::from_arc(new_columns); + + let Some(old_ptr) = self.columns.swap(Some(new_ptr)) else { + return; + }; + + // drop the old `Arc` + old_ptr.into_arc(); + } +} + +impl fmt::Debug for ColumnCache { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ColumnCache") + .field("columns", &self.get_columns()) + .finish() + } +} + +impl PartialEq for ColumnCache { + fn eq(&self, other: &Self) -> bool { + self.get_columns() == other.get_columns() + } +} + +impl Eq for ColumnCache {} + +impl Drop for ColumnCache { + fn drop(&mut self) { + // drop `Arc` if any + self.columns.load().map(|x| x.into_arc()); + } +} diff --git a/vendor/mysql-28.0.0/src/conn/stmt_cache.rs b/vendor/mysql-28.0.0/src/conn/stmt_cache.rs new file mode 100644 index 0000000000..48d0b22f9f --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/stmt_cache.rs @@ -0,0 +1,119 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use lru::LruCache; +use twox_hash::XxHash64; + +use std::{ + borrow::Borrow, + collections::HashMap, + hash::{BuildHasherDefault, Hash}, + sync::Arc, +}; + +use crate::conn::stmt::InnerStmt; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct QueryString(pub Arc>); + +impl Borrow<[u8]> for QueryString { + fn borrow(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl PartialEq<[u8]> for QueryString { + fn eq(&self, other: &[u8]) -> bool { + &**self.0.as_ref() == other + } +} + +pub struct Entry { + pub stmt: Arc, + pub query: QueryString, +} + +#[derive(Debug)] +pub struct StmtCache { + cap: usize, + cache: LruCache, + query_map: HashMap>, +} + +impl StmtCache { + pub fn new(cap: usize) -> StmtCache { + StmtCache { + cap, + cache: LruCache::unbounded(), + query_map: Default::default(), + } + } + + pub fn contains_query(&self, key: &T) -> bool + where + QueryString: Borrow, + T: Hash + Eq, + T: ?Sized, + { + self.query_map.contains_key(key) + } + + pub fn by_query(&mut self, query: &T) -> Option<&Entry> + where + QueryString: Borrow, + QueryString: PartialEq, + T: Hash + Eq, + T: ?Sized, + { + let id = self.query_map.get(query).cloned(); + match id { + Some(id) => self.cache.get(&id), + None => None, + } + } + + pub fn put(&mut self, query: Arc>, stmt: Arc) -> Option> { + if self.cap == 0 { + return None; + } + + let query = QueryString(query); + + self.query_map.insert(query.clone(), stmt.id()); + self.cache.put(stmt.id(), Entry { stmt, query }); + + if self.cache.len() > self.cap { + if let Some((_, entry)) = self.cache.pop_lru() { + self.query_map.remove(&**entry.query.0.as_ref()); + return Some(entry.stmt); + } + } + + None + } + + pub fn clear(&mut self) { + self.query_map.clear(); + self.cache.clear(); + } + + pub fn remove(&mut self, id: u32) { + if let Some(entry) = self.cache.pop(&id) { + self.query_map.remove::<[u8]>(entry.query.borrow()); + } + } + + #[cfg(test)] + pub fn iter(&self) -> impl Iterator { + self.cache.iter() + } + + pub fn into_iter(mut self) -> impl Iterator { + std::iter::from_fn(move || self.cache.pop_lru()) + } +} diff --git a/vendor/mysql-28.0.0/src/conn/transaction.rs b/vendor/mysql-28.0.0/src/conn/transaction.rs new file mode 100644 index 0000000000..ccb09c3da0 --- /dev/null +++ b/vendor/mysql-28.0.0/src/conn/transaction.rs @@ -0,0 +1,212 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use mysql_common::packets::OkPacket; + +use std::{borrow::Cow, fmt}; + +use crate::{ + conn::{ + query_result::{Binary, Text}, + ConnMut, + }, + prelude::*, + LocalInfileHandler, Params, QueryResult, Result, Statement, +}; + +/// MySql transaction options. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +pub struct TxOpts { + with_consistent_snapshot: bool, + isolation_level: Option, + access_mode: Option, +} + +impl TxOpts { + /// Returns the value of the characteristic. + pub fn with_consistent_snapshot(&self) -> bool { + self.with_consistent_snapshot + } + + /// Returns the access mode value. + pub fn access_mode(&self) -> Option { + self.access_mode + } + + /// Returns the isolation level value. + pub fn isolation_level(&self) -> Option { + self.isolation_level + } + + /// Turns on/off the `WITH CONSISTENT SNAPSHOT` tx characteristic (defaults to `false`). + pub fn set_with_consistent_snapshot(mut self, val: bool) -> Self { + self.with_consistent_snapshot = val; + self + } + + /// Defines the transaction access mode (defaults to `None`, i.e unspecified). + pub fn set_access_mode(mut self, access_mode: Option) -> Self { + self.access_mode = access_mode; + self + } + + /// Defines the transaction isolation level (defaults to `None`, i.e. unspecified). + pub fn set_isolation_level(mut self, level: Option) -> Self { + self.isolation_level = level; + self + } +} + +/// MySql transaction access mode. +#[derive(PartialEq, Eq, Clone, Copy, Debug, Hash)] +#[repr(u8)] +pub enum AccessMode { + ReadOnly, + ReadWrite, +} + +/// MySql transaction isolation level. +#[derive(PartialEq, Eq, Clone, Copy, Debug, Hash)] +#[repr(u8)] +pub enum IsolationLevel { + ReadUncommitted, + ReadCommitted, + RepeatableRead, + Serializable, +} + +impl fmt::Display for IsolationLevel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + IsolationLevel::ReadUncommitted => write!(f, "READ UNCOMMITTED"), + IsolationLevel::ReadCommitted => write!(f, "READ COMMITTED"), + IsolationLevel::RepeatableRead => write!(f, "REPEATABLE READ"), + IsolationLevel::Serializable => write!(f, "SERIALIZABLE"), + } + } +} + +#[derive(Debug)] +pub struct Transaction<'a> { + pub(crate) conn: ConnMut<'a, 'static, 'static>, + committed: bool, + rolled_back: bool, + restore_local_infile_handler: Option, +} + +impl Transaction<'_> { + pub(crate) fn new<'a>(conn: ConnMut<'a, 'static, 'static>) -> Transaction<'a> { + let handler = conn.0.local_infile_handler.clone(); + Transaction { + conn, + committed: false, + rolled_back: false, + restore_local_infile_handler: handler, + } + } + + /// Will consume and commit transaction. + pub fn commit(mut self) -> Result<()> { + self.conn.query_drop("COMMIT")?; + self.committed = true; + Ok(()) + } + + /// Will consume and rollback transaction. You also can rely on `Drop` implementation but it + /// will swallow errors. + pub fn rollback(mut self) -> Result<()> { + self.conn.query_drop("ROLLBACK")?; + self.rolled_back = true; + Ok(()) + } + + /// A way to override local infile handler for this transaction. + /// Destructor of transaction will restore original handler. + pub fn set_local_infile_handler(&mut self, handler: Option) { + self.conn.set_local_infile_handler(handler); + } + + /// Returns the number of affected rows, reported by the server. + pub fn affected_rows(&self) -> u64 { + self.conn.affected_rows() + } + + /// Returns the last insert id of the last query, if any. + pub fn last_insert_id(&self) -> Option { + self.conn + .0 + .ok_packet + .as_ref() + .and_then(OkPacket::last_insert_id) + } + + /// Returns the warnings count, reported by the server. + pub fn warnings(&self) -> u16 { + self.conn.warnings() + } + + /// [Info], reported by the server. + /// + /// Will be empty if not defined. + /// + /// [Info]: http://dev.mysql.com/doc/internals/en/packet-OK_Packet.html + pub fn info_ref(&self) -> &[u8] { + self.conn.info_ref() + } + + /// [Info], reported by the server. + /// + /// Will be empty if not defined. + /// + /// [Info]: http://dev.mysql.com/doc/internals/en/packet-OK_Packet.html + pub fn info_str(&self) -> Cow<'_, str> { + self.conn.info_str() + } +} + +impl<'a> Queryable for Transaction<'a> { + fn query_iter>(&mut self, query: T) -> Result> { + self.conn.query_iter(query) + } + + fn prep>(&mut self, query: T) -> Result { + self.conn.prep(query) + } + + fn close(&mut self, stmt: Statement) -> Result<()> { + self.conn.close(stmt) + } + + fn exec_iter(&mut self, stmt: S, params: P) -> Result> + where + S: AsStatement, + P: Into, + { + self.conn.exec_iter(stmt, params) + } + + fn exec_batch(&mut self, stmt: S, params: I) -> Result<()> + where + Self: Sized, + S: AsStatement, + P: Into, + I: IntoIterator, + { + self.conn.exec_batch(stmt, params) + } +} + +impl<'a> Drop for Transaction<'a> { + /// Will rollback transaction. + fn drop(&mut self) { + if !self.committed && !self.rolled_back { + let _ = self.conn.query_drop("ROLLBACK"); + } + self.conn.0.local_infile_handler = self.restore_local_infile_handler.take(); + } +} diff --git a/vendor/mysql-28.0.0/src/error/mod.rs b/vendor/mysql-28.0.0/src/error/mod.rs new file mode 100644 index 0000000000..ed046d58fd --- /dev/null +++ b/vendor/mysql-28.0.0/src/error/mod.rs @@ -0,0 +1,1124 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use mysql_common::{ + named_params::MixedParamsError, + packets::{self, BulkExecuteRequestBuilderError, BulkExecuteRequestError}, + params::ParamsError, + proto::codec::error::PacketCodecError, + row::convert::FromRowError, + value::convert::FromValueError, +}; +use url::ParseError; + +use std::{error, fmt, io, result, sync}; + +use crate::{Row, Value}; + +pub mod tls; + +impl<'a> From> for MySqlError { + fn from(x: packets::ServerError<'a>) -> MySqlError { + MySqlError { + state: x + .sql_state_ref() + .map(|x| x.as_str().as_ref().to_owned()) + .unwrap_or_else(|| "HY000".to_owned()), + code: x.error_code(), + message: x.message_str().into_owned(), + } + } +} + +#[derive(Eq, PartialEq, Clone)] +pub struct MySqlError { + pub state: String, + pub message: String, + pub code: u16, +} + +impl fmt::Display for MySqlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ERROR {} ({}): {}", self.code, self.state, self.message) + } +} + +impl fmt::Debug for MySqlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +impl error::Error for MySqlError { + fn description(&self) -> &str { + "Error returned by a server" + } +} + +pub enum Error { + IoError(io::Error), + CodecError(mysql_common::proto::codec::error::PacketCodecError), + MySqlError(MySqlError), + DriverError(DriverError), + UrlError(UrlError), + #[cfg(any(feature = "native-tls", feature = "rustls"))] + #[cfg_attr( + docsrs, + doc(cfg(any( + feature = "native-tls", + feature = "rustls-tls", + feature = "rustls-tls-ring" + ))) + )] + TlsError(tls::TlsError), + FromValueError(Value), + FromRowError(Row), +} + +impl Error { + #[doc(hidden)] + pub fn is_connectivity_error(&self) -> bool { + match self { + #[cfg(any(feature = "native-tls", feature = "rustls"))] + Error::TlsError(_) => true, + Error::IoError(_) | Error::DriverError(_) | Error::CodecError(_) => true, + Error::MySqlError(_) + | Error::UrlError(_) + | Error::FromValueError(_) + | Error::FromRowError(_) => false, + } + } + + #[doc(hidden)] + pub fn server_disconnected() -> Self { + Error::IoError(io::Error::new( + io::ErrorKind::BrokenPipe, + "server disconnected", + )) + } +} + +impl error::Error for Error { + fn cause(&self) -> Option<&dyn error::Error> { + match *self { + Error::IoError(ref err) => Some(err), + Error::DriverError(ref err) => Some(err), + Error::MySqlError(ref err) => Some(err), + Error::UrlError(ref err) => Some(err), + #[cfg(any(feature = "native-tls", feature = "rustls"))] + Error::TlsError(ref err) => Some(err), + _ => None, + } + } +} + +impl From for Error { + fn from(FromValueError(value): FromValueError) -> Error { + Error::FromValueError(value) + } +} + +impl From for Error { + fn from(FromRowError(row): FromRowError) -> Error { + Error::FromRowError(row) + } +} + +impl From for Error { + fn from(_: MixedParamsError) -> Error { + Error::DriverError(DriverError::MixedParams) + } +} + +impl From for Error { + fn from(err: io::Error) -> Error { + Error::IoError(err) + } +} + +impl From for Error { + fn from(err: DriverError) -> Error { + Error::DriverError(err) + } +} + +impl From for Error { + fn from(x: MySqlError) -> Error { + Error::MySqlError(x) + } +} + +impl From for Error { + fn from(err: PacketCodecError) -> Self { + Error::CodecError(err) + } +} + +impl From for Error { + fn from(err: std::convert::Infallible) -> Self { + match err {} + } +} + +impl From for Error { + fn from(err: UrlError) -> Error { + Error::UrlError(err) + } +} + +impl From> for Error { + fn from(_: sync::PoisonError) -> Error { + Error::DriverError(DriverError::PoisonedPoolMutex) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Error::IoError(ref err) => write!(f, "IoError {{ {} }}", err), + Error::CodecError(ref err) => write!(f, "CodecError {{ {} }}", err), + Error::MySqlError(ref err) => write!(f, "MySqlError {{ {} }}", err), + Error::DriverError(ref err) => write!(f, "DriverError {{ {} }}", err), + Error::UrlError(ref err) => write!(f, "UrlError {{ {} }}", err), + #[cfg(any(feature = "native-tls", feature = "rustls"))] + Error::TlsError(ref err) => write!(f, "TlsError {{ {} }}", err), + Error::FromRowError(_) => "from row conversion error".fmt(f), + Error::FromValueError(_) => "from value conversion error".fmt(f), + } + } +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +#[derive(PartialEq, Clone)] +pub enum DriverError { + ConnectTimeout, + // (address, description) + CouldNotConnect(Option<(String, String, io::ErrorKind)>), + UnsupportedProtocol(u8), + PacketOutOfSync, + PacketTooLarge, + Protocol41NotSet, + UnexpectedPacket, + MismatchedStmtParams(u16, usize), + InvalidPoolConstraints, + SetupError, + TlsNotSupported, + CouldNotParseVersion, + ReadOnlyTransNotSupported, + PoisonedPoolMutex, + Timeout, + Params(ParamsError), + MixedParams, + UnknownAuthPlugin(String), + OldMysqlPasswordDisabled, + CleartextPluginDisabled, + BulkExecute(BulkExecuteRequestError), + InvalidParsecSalt, +} + +impl From for DriverError { + fn from(value: BulkExecuteRequestBuilderError) -> Self { + match value { + BulkExecuteRequestBuilderError::Request(x) => Self::from(x), + BulkExecuteRequestBuilderError::Params(x) => Self::from(x), + } + } +} + +impl From for DriverError { + fn from(value: BulkExecuteRequestError) -> Self { + Self::BulkExecute(value) + } +} + +impl From for DriverError { + fn from(value: ParamsError) -> Self { + Self::Params(value) + } +} + +impl error::Error for DriverError { + fn description(&self) -> &str { + "MySql driver error" + } +} + +impl fmt::Display for DriverError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DriverError::ConnectTimeout => write!(f, "Could not connect: connection timeout"), + DriverError::CouldNotConnect(None) => { + write!(f, "Could not connect: address not specified") + } + DriverError::CouldNotConnect(Some((ref addr, ref desc, _))) => { + write!(f, "Could not connect to address `{}': {}", addr, desc) + } + DriverError::UnsupportedProtocol(proto_version) => { + write!(f, "Unsupported protocol version {}", proto_version) + } + DriverError::PacketOutOfSync => write!(f, "Packet out of sync"), + DriverError::PacketTooLarge => write!(f, "Packet too large"), + DriverError::Protocol41NotSet => write!(f, "Server must set CLIENT_PROTOCOL_41 flag"), + DriverError::UnexpectedPacket => write!(f, "Unexpected packet"), + DriverError::MismatchedStmtParams(exp, prov) => write!( + f, + "Statement takes {} parameters but {} was supplied", + exp, prov + ), + DriverError::InvalidPoolConstraints => write!(f, "Invalid pool constraints"), + DriverError::SetupError => write!(f, "Could not setup connection"), + DriverError::TlsNotSupported => write!( + f, + "Client requires secure connection but server \ + does not have this capability" + ), + DriverError::CouldNotParseVersion => write!(f, "Could not parse MySQL version"), + DriverError::ReadOnlyTransNotSupported => write!( + f, + "Read-only transactions does not supported in your MySQL version" + ), + DriverError::PoisonedPoolMutex => write!(f, "Poisoned pool mutex"), + DriverError::Timeout => write!(f, "Operation timed out"), + DriverError::Params(error) => error.fmt(f), + DriverError::MixedParams => write!( + f, + "Can not mix named and positional parameters in one statement" + ), + DriverError::UnknownAuthPlugin(ref name) => { + write!(f, "Unknown authentication protocol: `{}`", name) + } + DriverError::OldMysqlPasswordDisabled => { + write!( + f, + "`old_mysql_password` plugin is insecure and disabled by default", + ) + } + DriverError::CleartextPluginDisabled => { + write!(f, "mysql_clear_password must be enabled on the client side") + } + DriverError::BulkExecute(e) => { + write!(f, "Bulk execute error: {e}") + } + DriverError::InvalidParsecSalt => { + write!(f, "Could not parse Parsec extended salt packet") + } + } + } +} + +impl fmt::Debug for DriverError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +#[derive(Eq, PartialEq, Clone)] +pub enum UrlError { + ParseError(ParseError), + UnsupportedScheme(String), + /// (feature_name, parameter_name) + FeatureRequired(String, String), + /// (feature_name, value) + InvalidValue(String, String), + UnknownParameter(String), + InvalidPoolConstraints { + min: usize, + max: usize, + }, + BadUrl, +} + +impl error::Error for UrlError { + fn description(&self) -> &str { + "Database connection URL error" + } +} + +impl fmt::Display for UrlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + UrlError::ParseError(ref err) => write!(f, "URL ParseError {{ {} }}", err), + UrlError::UnsupportedScheme(ref s) => write!(f, "URL scheme `{}' is not supported", s), + UrlError::FeatureRequired(ref feature, ref parameter) => write!( + f, + "Url parameter `{}' requires {} feature", + parameter, feature + ), + UrlError::InvalidValue(ref parameter, ref value) => write!( + f, + "Invalid value `{}' for URL parameter `{}'", + value, parameter + ), + UrlError::UnknownParameter(ref parameter) => { + write!(f, "Unknown URL parameter `{}'", parameter) + } + UrlError::InvalidPoolConstraints { min, max } => { + write!( + f, + "Invalid pool constraints: pool_min ({}) > pool_max ({})", + min, max + ) + } + UrlError::BadUrl => write!(f, "Invalid or incomplete connection URL"), + } + } +} + +impl fmt::Debug for UrlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +impl From for UrlError { + fn from(x: ParseError) -> UrlError { + UrlError::ParseError(x) + } +} + +pub type Result = result::Result; + +/// Server error codes (u16) +#[allow(non_camel_case_types)] +#[derive(Clone, Eq, PartialEq, Debug, Copy)] +#[repr(u16)] +pub enum ServerError { + ER_HASHCHK = 1000u16, + ER_NISAMCHK = 1001u16, + ER_NO = 1002u16, + ER_YES = 1003u16, + ER_CANT_CREATE_FILE = 1004u16, + ER_CANT_CREATE_TABLE = 1005u16, + ER_CANT_CREATE_DB = 1006u16, + ER_DB_CREATE_EXISTS = 1007u16, + ER_DB_DROP_EXISTS = 1008u16, + ER_DB_DROP_DELETE = 1009u16, + ER_DB_DROP_RMDIR = 1010u16, + ER_CANT_DELETE_FILE = 1011u16, + ER_CANT_FIND_SYSTEM_REC = 1012u16, + ER_CANT_GET_STAT = 1013u16, + ER_CANT_GET_WD = 1014u16, + ER_CANT_LOCK = 1015u16, + ER_CANT_OPEN_FILE = 1016u16, + ER_FILE_NOT_FOUND = 1017u16, + ER_CANT_READ_DIR = 1018u16, + ER_CANT_SET_WD = 1019u16, + ER_CHECKREAD = 1020u16, + ER_DISK_FULL = 1021u16, + ER_DUP_KEY = 1022u16, + ER_ERROR_ON_CLOSE = 1023u16, + ER_ERROR_ON_READ = 1024u16, + ER_ERROR_ON_RENAME = 1025u16, + ER_ERROR_ON_WRITE = 1026u16, + ER_FILE_USED = 1027u16, + ER_FILSORT_ABORT = 1028u16, + ER_FORM_NOT_FOUND = 1029u16, + ER_GET_ERRNO = 1030u16, + ER_ILLEGAL_HA = 1031u16, + ER_KEY_NOT_FOUND = 1032u16, + ER_NOT_FORM_FILE = 1033u16, + ER_NOT_KEYFILE = 1034u16, + ER_OLD_KEYFILE = 1035u16, + ER_OPEN_AS_READONLY = 1036u16, + ER_OUTOFMEMORY = 1037u16, + ER_OUT_OF_SORTMEMORY = 1038u16, + ER_UNEXPECTED_EOF = 1039u16, + ER_CON_COUNT_ERROR = 1040u16, + ER_OUT_OF_RESOURCES = 1041u16, + ER_BAD_HOST_ERROR = 1042u16, + ER_HANDSHAKE_ERROR = 1043u16, + ER_DBACCESS_DENIED_ERROR = 1044u16, + ER_ACCESS_DENIED_ERROR = 1045u16, + ER_NO_DB_ERROR = 1046u16, + ER_UNKNOWN_COM_ERROR = 1047u16, + ER_BAD_NULL_ERROR = 1048u16, + ER_BAD_DB_ERROR = 1049u16, + ER_TABLE_EXISTS_ERROR = 1050u16, + ER_BAD_TABLE_ERROR = 1051u16, + ER_NON_UNIQ_ERROR = 1052u16, + ER_SERVER_SHUTDOWN = 1053u16, + ER_BAD_FIELD_ERROR = 1054u16, + ER_WRONG_FIELD_WITH_GROUP = 1055u16, + ER_WRONG_GROUP_FIELD = 1056u16, + ER_WRONG_SUM_SELECT = 1057u16, + ER_WRONG_VALUE_COUNT = 1058u16, + ER_TOO_LONG_IDENT = 1059u16, + ER_DUP_FIELDNAME = 1060u16, + ER_DUP_KEYNAME = 1061u16, + ER_DUP_ENTRY = 1062u16, + ER_WRONG_FIELD_SPEC = 1063u16, + ER_PARSE_ERROR = 1064u16, + ER_EMPTY_QUERY = 1065u16, + ER_NONUNIQ_TABLE = 1066u16, + ER_INVALID_DEFAULT = 1067u16, + ER_MULTIPLE_PRI_KEY = 1068u16, + ER_TOO_MANY_KEYS = 1069u16, + ER_TOO_MANY_KEY_PARTS = 1070u16, + ER_TOO_LONG_KEY = 1071u16, + ER_KEY_COLUMN_DOES_NOT_EXITS = 1072u16, + ER_BLOB_USED_AS_KEY = 1073u16, + ER_TOO_BIG_FIELDLENGTH = 1074u16, + ER_WRONG_AUTO_KEY = 1075u16, + ER_READY = 1076u16, + ER_NORMAL_SHUTDOWN = 1077u16, + ER_GOT_SIGNAL = 1078u16, + ER_SHUTDOWN_COMPLETE = 1079u16, + ER_FORCING_CLOSE = 1080u16, + ER_IPSOCK_ERROR = 1081u16, + ER_NO_SUCH_INDEX = 1082u16, + ER_WRONG_FIELD_TERMINATORS = 1083u16, + ER_BLOBS_AND_NO_TERMINATED = 1084u16, + ER_TEXTFILE_NOT_READABLE = 1085u16, + ER_FILE_EXISTS_ERROR = 1086u16, + ER_LOAD_INFO = 1087u16, + ER_ALTER_INFO = 1088u16, + ER_WRONG_SUB_KEY = 1089u16, + ER_CANT_REMOVE_ALL_FIELDS = 1090u16, + ER_CANT_DROP_FIELD_OR_KEY = 1091u16, + ER_INSERT_INFO = 1092u16, + ER_UPDATE_TABLE_USED = 1093u16, + ER_NO_SUCH_THREAD = 1094u16, + ER_KILL_DENIED_ERROR = 1095u16, + ER_NO_TABLES_USED = 1096u16, + ER_TOO_BIG_SET = 1097u16, + ER_NO_UNIQUE_LOGFILE = 1098u16, + ER_TABLE_NOT_LOCKED_FOR_WRITE = 1099u16, + ER_TABLE_NOT_LOCKED = 1100u16, + ER_BLOB_CANT_HAVE_DEFAULT = 1101u16, + ER_WRONG_DB_NAME = 1102u16, + ER_WRONG_TABLE_NAME = 1103u16, + ER_TOO_BIG_SELECT = 1104u16, + ER_UNKNOWN_ERROR = 1105u16, + ER_UNKNOWN_PROCEDURE = 1106u16, + ER_WRONG_PARAMCOUNT_TO_PROCEDURE = 1107u16, + ER_WRONG_PARAMETERS_TO_PROCEDURE = 1108u16, + ER_UNKNOWN_TABLE = 1109u16, + ER_FIELD_SPECIFIED_TWICE = 1110u16, + ER_INVALID_GROUP_FUNC_USE = 1111u16, + ER_UNSUPPORTED_EXTENSION = 1112u16, + ER_TABLE_MUST_HAVE_COLUMNS = 1113u16, + ER_RECORD_FILE_FULL = 1114u16, + ER_UNKNOWN_CHARACTER_SET = 1115u16, + ER_TOO_MANY_TABLES = 1116u16, + ER_TOO_MANY_FIELDS = 1117u16, + ER_TOO_BIG_ROWSIZE = 1118u16, + ER_STACK_OVERRUN = 1119u16, + ER_WRONG_OUTER_JOIN = 1120u16, + ER_NULL_COLUMN_IN_INDEX = 1121u16, + ER_CANT_FIND_UDF = 1122u16, + ER_CANT_INITIALIZE_UDF = 1123u16, + ER_UDF_NO_PATHS = 1124u16, + ER_UDF_EXISTS = 1125u16, + ER_CANT_OPEN_LIBRARY = 1126u16, + ER_CANT_FIND_DL_ENTRY = 1127u16, + ER_FUNCTION_NOT_DEFINED = 1128u16, + ER_HOST_IS_BLOCKED = 1129u16, + ER_HOST_NOT_PRIVILEGED = 1130u16, + ER_PASSWORD_ANONYMOUS_USER = 1131u16, + ER_PASSWORD_NOT_ALLOWED = 1132u16, + ER_PASSWORD_NO_MATCH = 1133u16, + ER_UPDATE_INFO = 1134u16, + ER_CANT_CREATE_THREAD = 1135u16, + ER_WRONG_VALUE_COUNT_ON_ROW = 1136u16, + ER_CANT_REOPEN_TABLE = 1137u16, + ER_INVALID_USE_OF_NULL = 1138u16, + ER_REGEXP_ERROR = 1139u16, + ER_MIX_OF_GROUP_FUNC_AND_FIELDS = 1140u16, + ER_NONEXISTING_GRANT = 1141u16, + ER_TABLEACCESS_DENIED_ERROR = 1142u16, + ER_COLUMNACCESS_DENIED_ERROR = 1143u16, + ER_ILLEGAL_GRANT_FOR_TABLE = 1144u16, + ER_GRANT_WRONG_HOST_OR_USER = 1145u16, + ER_NO_SUCH_TABLE = 1146u16, + ER_NONEXISTING_TABLE_GRANT = 1147u16, + ER_NOT_ALLOWED_COMMAND = 1148u16, + ER_SYNTAX_ERROR = 1149u16, + ER_DELAYED_CANT_CHANGE_LOCK = 1150u16, + ER_TOO_MANY_DELAYED_THREADS = 1151u16, + ER_ABORTING_CONNECTION = 1152u16, + ER_NET_PACKET_TOO_LARGE = 1153u16, + ER_NET_READ_ERROR_FROM_PIPE = 1154u16, + ER_NET_FCNTL_ERROR = 1155u16, + ER_NET_PACKETS_OUT_OF_ORDER = 1156u16, + ER_NET_UNCOMPRESS_ERROR = 1157u16, + ER_NET_READ_ERROR = 1158u16, + ER_NET_READ_INTERRUPTED = 1159u16, + ER_NET_ERROR_ON_WRITE = 1160u16, + ER_NET_WRITE_INTERRUPTED = 1161u16, + ER_TOO_LONG_STRING = 1162u16, + ER_TABLE_CANT_HANDLE_BLOB = 1163u16, + ER_TABLE_CANT_HANDLE_AUTO_INCREMENT = 1164u16, + ER_DELAYED_INSERT_TABLE_LOCKED = 1165u16, + ER_WRONG_COLUMN_NAME = 1166u16, + ER_WRONG_KEY_COLUMN = 1167u16, + ER_WRONG_MRG_TABLE = 1168u16, + ER_DUP_UNIQUE = 1169u16, + ER_BLOB_KEY_WITHOUT_LENGTH = 1170u16, + ER_PRIMARY_CANT_HAVE_NULL = 1171u16, + ER_TOO_MANY_ROWS = 1172u16, + ER_REQUIRES_PRIMARY_KEY = 1173u16, + ER_NO_RAID_COMPILED = 1174u16, + ER_UPDATE_WITHOUT_KEY_IN_SAFE_MODE = 1175u16, + ER_KEY_DOES_NOT_EXITS = 1176u16, + ER_CHECK_NO_SUCH_TABLE = 1177u16, + ER_CHECK_NOT_IMPLEMENTED = 1178u16, + ER_CANT_DO_THIS_DURING_AN_TRANSACTION = 1179u16, + ER_ERROR_DURING_COMMIT = 1180u16, + ER_ERROR_DURING_ROLLBACK = 1181u16, + ER_ERROR_DURING_FLUSH_LOGS = 1182u16, + ER_ERROR_DURING_CHECKPOINT = 1183u16, + ER_NEW_ABORTING_CONNECTION = 1184u16, + ER_DUMP_NOT_IMPLEMENTED = 1185u16, + ER_FLUSH_MASTER_BINLOG_CLOSED = 1186u16, + ER_INDEX_REBUILD = 1187u16, + ER_MASTER = 1188u16, + ER_MASTER_NET_READ = 1189u16, + ER_MASTER_NET_WRITE = 1190u16, + ER_FT_MATCHING_KEY_NOT_FOUND = 1191u16, + ER_LOCK_OR_ACTIVE_TRANSACTION = 1192u16, + ER_UNKNOWN_SYSTEM_VARIABLE = 1193u16, + ER_CRASHED_ON_USAGE = 1194u16, + ER_CRASHED_ON_REPAIR = 1195u16, + ER_WARNING_NOT_COMPLETE_ROLLBACK = 1196u16, + ER_TRANS_CACHE_FULL = 1197u16, + ER_SLAVE_MUST_STOP = 1198u16, + ER_SLAVE_NOT_RUNNING = 1199u16, + ER_BAD_SLAVE = 1200u16, + ER_MASTER_INFO = 1201u16, + ER_SLAVE_THREAD = 1202u16, + ER_TOO_MANY_USER_CONNECTIONS = 1203u16, + ER_SET_CONSTANTS_ONLY = 1204u16, + ER_LOCK_WAIT_TIMEOUT = 1205u16, + ER_LOCK_TABLE_FULL = 1206u16, + ER_READ_ONLY_TRANSACTION = 1207u16, + ER_DROP_DB_WITH_READ_LOCK = 1208u16, + ER_CREATE_DB_WITH_READ_LOCK = 1209u16, + ER_WRONG_ARGUMENTS = 1210u16, + ER_NO_PERMISSION_TO_CREATE_USER = 1211u16, + ER_UNION_TABLES_IN_DIFFERENT_DIR = 1212u16, + ER_LOCK_DEADLOCK = 1213u16, + ER_TABLE_CANT_HANDLE_FT = 1214u16, + ER_CANNOT_ADD_FOREIGN = 1215u16, + ER_NO_REFERENCED_ROW = 1216u16, + ER_ROW_IS_REFERENCED = 1217u16, + ER_CONNECT_TO_MASTER = 1218u16, + ER_QUERY_ON_MASTER = 1219u16, + ER_ERROR_WHEN_EXECUTING_COMMAND = 1220u16, + ER_WRONG_USAGE = 1221u16, + ER_WRONG_NUMBER_OF_COLUMNS_IN_SELECT = 1222u16, + ER_CANT_UPDATE_WITH_READLOCK = 1223u16, + ER_MIXING_NOT_ALLOWED = 1224u16, + ER_DUP_ARGUMENT = 1225u16, + ER_USER_LIMIT_REACHED = 1226u16, + ER_SPECIFIC_ACCESS_DENIED_ERROR = 1227u16, + ER_LOCAL_VARIABLE = 1228u16, + ER_GLOBAL_VARIABLE = 1229u16, + ER_NO_DEFAULT = 1230u16, + ER_WRONG_VALUE_FOR_VAR = 1231u16, + ER_WRONG_TYPE_FOR_VAR = 1232u16, + ER_VAR_CANT_BE_READ = 1233u16, + ER_CANT_USE_OPTION_HERE = 1234u16, + ER_NOT_SUPPORTED_YET = 1235u16, + ER_MASTER_FATAL_ERROR_READING_BINLOG = 1236u16, + ER_SLAVE_IGNORED_TABLE = 1237u16, + ER_INCORRECT_GLOBAL_LOCAL_VAR = 1238u16, + ER_WRONG_FK_DEF = 1239u16, + ER_KEY_REF_DO_NOT_MATCH_TABLE_REF = 1240u16, + ER_OPERAND_COLUMNS = 1241u16, + ER_SUBQUERY_NO_1_ROW = 1242u16, + ER_UNKNOWN_STMT_HANDLER = 1243u16, + ER_CORRUPT_HELP_DB = 1244u16, + ER_CYCLIC_REFERENCE = 1245u16, + ER_AUTO_CONVERT = 1246u16, + ER_ILLEGAL_REFERENCE = 1247u16, + ER_DERIVED_MUST_HAVE_ALIAS = 1248u16, + ER_SELECT_REDUCED = 1249u16, + ER_TABLENAME_NOT_ALLOWED_HERE = 1250u16, + ER_NOT_SUPPORTED_AUTH_MODE = 1251u16, + ER_SPATIAL_CANT_HAVE_NULL = 1252u16, + ER_COLLATION_CHARSET_MISMATCH = 1253u16, + ER_SLAVE_WAS_RUNNING = 1254u16, + ER_SLAVE_WAS_NOT_RUNNING = 1255u16, + ER_TOO_BIG_FOR_UNCOMPRESS = 1256u16, + ER_ZLIB_Z_MEM_ERROR = 1257u16, + ER_ZLIB_Z_BUF_ERROR = 1258u16, + ER_ZLIB_Z_DATA_ERROR = 1259u16, + ER_CUT_VALUE_GROUP_CONCAT = 1260u16, + ER_WARN_TOO_FEW_RECORDS = 1261u16, + ER_WARN_TOO_MANY_RECORDS = 1262u16, + ER_WARN_NULL_TO_NOTNULL = 1263u16, + ER_WARN_DATA_OUT_OF_RANGE = 1264u16, + WARN_DATA_TRUNCATED = 1265u16, + ER_WARN_USING_OTHER_HANDLER = 1266u16, + ER_CANT_AGGREGATE_2COLLATIONS = 1267u16, + ER_DROP_USER = 1268u16, + ER_REVOKE_GRANTS = 1269u16, + ER_CANT_AGGREGATE_3COLLATIONS = 1270u16, + ER_CANT_AGGREGATE_NCOLLATIONS = 1271u16, + ER_VARIABLE_IS_NOT_STRUCT = 1272u16, + ER_UNKNOWN_COLLATION = 1273u16, + ER_SLAVE_IGNORED_SSL_PARAMS = 1274u16, + ER_SERVER_IS_IN_SECURE_AUTH_MODE = 1275u16, + ER_WARN_FIELD_RESOLVED = 1276u16, + ER_BAD_SLAVE_UNTIL_COND = 1277u16, + ER_MISSING_SKIP_SLAVE = 1278u16, + ER_UNTIL_COND_IGNORED = 1279u16, + ER_WRONG_NAME_FOR_INDEX = 1280u16, + ER_WRONG_NAME_FOR_CATALOG = 1281u16, + ER_WARN_QC_RESIZE = 1282u16, + ER_BAD_FT_COLUMN = 1283u16, + ER_UNKNOWN_KEY_CACHE = 1284u16, + ER_WARN_HOSTNAME_WONT_WORK = 1285u16, + ER_UNKNOWN_STORAGE_ENGINE = 1286u16, + ER_WARN_DEPRECATED_SYNTAX = 1287u16, + ER_NON_UPDATABLE_TABLE = 1288u16, + ER_FEATURE_DISABLED = 1289u16, + ER_OPTION_PREVENTS_STATEMENT = 1290u16, + ER_DUPLICATED_VALUE_IN_TYPE = 1291u16, + ER_TRUNCATED_WRONG_VALUE = 1292u16, + ER_TOO_MUCH_AUTO_TIMESTAMP_COLS = 1293u16, + ER_INVALID_ON_UPDATE = 1294u16, + ER_UNSUPPORTED_PS = 1295u16, + ER_GET_ERRMSG = 1296u16, + ER_GET_TEMPORARY_ERRMSG = 1297u16, + ER_UNKNOWN_TIME_ZONE = 1298u16, + ER_WARN_INVALID_TIMESTAMP = 1299u16, + ER_INVALID_CHARACTER_STRING = 1300u16, + ER_WARN_ALLOWED_PACKET_OVERFLOWED = 1301u16, + ER_CONFLICTING_DECLARATIONS = 1302u16, + ER_SP_NO_RECURSIVE_CREATE = 1303u16, + ER_SP_ALREADY_EXISTS = 1304u16, + ER_SP_DOES_NOT_EXIST = 1305u16, + ER_SP_DROP_FAILED = 1306u16, + ER_SP_STORE_FAILED = 1307u16, + ER_SP_LILABEL_MISMATCH = 1308u16, + ER_SP_LABEL_REDEFINE = 1309u16, + ER_SP_LABEL_MISMATCH = 1310u16, + ER_SP_UNINIT_VAR = 1311u16, + ER_SP_BADSELECT = 1312u16, + ER_SP_BADRETURN = 1313u16, + ER_SP_BADSTATEMENT = 1314u16, + ER_UPDATE_LOG_DEPRECATED_IGNORED = 1315u16, + ER_UPDATE_LOG_DEPRECATED_TRANSLATED = 1316u16, + ER_QUERY_INTERRUPTED = 1317u16, + ER_SP_WRONG_NO_OF_ARGS = 1318u16, + ER_SP_COND_MISMATCH = 1319u16, + ER_SP_NORETURN = 1320u16, + ER_SP_NORETURNEND = 1321u16, + ER_SP_BAD_CURSOR_QUERY = 1322u16, + ER_SP_BAD_CURSOR_SELECT = 1323u16, + ER_SP_CURSOR_MISMATCH = 1324u16, + ER_SP_CURSOR_ALREADY_OPEN = 1325u16, + ER_SP_CURSOR_NOT_OPEN = 1326u16, + ER_SP_UNDECLARED_VAR = 1327u16, + ER_SP_WRONG_NO_OF_FETCH_ARGS = 1328u16, + ER_SP_FETCH_NO_DATA = 1329u16, + ER_SP_DUP_PARAM = 1330u16, + ER_SP_DUP_VAR = 1331u16, + ER_SP_DUP_COND = 1332u16, + ER_SP_DUP_CURS = 1333u16, + ER_SP_CANT_ALTER = 1334u16, + ER_SP_SUBSELECT_NYI = 1335u16, + ER_STMT_NOT_ALLOWED_IN_SF_OR_TRG = 1336u16, + ER_SP_VARCOND_AFTER_CURSHNDLR = 1337u16, + ER_SP_CURSOR_AFTER_HANDLER = 1338u16, + ER_SP_CASE_NOT_FOUND = 1339u16, + ER_FPARSER_TOO_BIG_FILE = 1340u16, + ER_FPARSER_BAD_HEADER = 1341u16, + ER_FPARSER_EOF_IN_COMMENT = 1342u16, + ER_FPARSER_ERROR_IN_PARAMETER = 1343u16, + ER_FPARSER_EOF_IN_UNKNOWN_PARAMETER = 1344u16, + ER_VIEW_NO_EXPLAIN = 1345u16, + ER_FRM_UNKNOWN_TYPE = 1346u16, + ER_WRONG_OBJECT = 1347u16, + ER_NONUPDATEABLE_COLUMN = 1348u16, + ER_VIEW_SELECT_DERIVED = 1349u16, + ER_VIEW_SELECT_CLAUSE = 1350u16, + ER_VIEW_SELECT_VARIABLE = 1351u16, + ER_VIEW_SELECT_TMPTABLE = 1352u16, + ER_VIEW_WRONG_LIST = 1353u16, + ER_WARN_VIEW_MERGE = 1354u16, + ER_WARN_VIEW_WITHOUT_KEY = 1355u16, + ER_VIEW_INVALID = 1356u16, + ER_SP_NO_DROP_SP = 1357u16, + ER_SP_GOTO_IN_HNDLR = 1358u16, + ER_TRG_ALREADY_EXISTS = 1359u16, + ER_TRG_DOES_NOT_EXIST = 1360u16, + ER_TRG_ON_VIEW_OR_TEMP_TABLE = 1361u16, + ER_TRG_CANT_CHANGE_ROW = 1362u16, + ER_TRG_NO_SUCH_ROW_IN_TRG = 1363u16, + ER_NO_DEFAULT_FOR_FIELD = 1364u16, + ER_DIVISION_BY_ZERO = 1365u16, + ER_TRUNCATED_WRONG_VALUE_FOR_FIELD = 1366u16, + ER_ILLEGAL_VALUE_FOR_TYPE = 1367u16, + ER_VIEW_NONUPD_CHECK = 1368u16, + ER_VIEW_CHECK_FAILED = 1369u16, + ER_PROCACCESS_DENIED_ERROR = 1370u16, + ER_RELAY_LOG_FAIL = 1371u16, + ER_PASSWD_LENGTH = 1372u16, + ER_UNKNOWN_TARGET_BINLOG = 1373u16, + ER_IO_ERR_LOG_INDEX_READ = 1374u16, + ER_BINLOG_PURGE_PROHIBITED = 1375u16, + ER_FSEEK_FAIL = 1376u16, + ER_BINLOG_PURGE_FATAL_ERR = 1377u16, + ER_LOG_IN_USE = 1378u16, + ER_LOG_PURGE_UNKNOWN_ERR = 1379u16, + ER_RELAY_LOG_INIT = 1380u16, + ER_NO_BINARY_LOGGING = 1381u16, + ER_RESERVED_SYNTAX = 1382u16, + ER_WSAS_FAILED = 1383u16, + ER_DIFF_GROUPS_PROC = 1384u16, + ER_NO_GROUP_FOR_PROC = 1385u16, + ER_ORDER_WITH_PROC = 1386u16, + ER_LOGGING_PROHIBIT_CHANGING_OF = 1387u16, + ER_NO_FILE_MAPPING = 1388u16, + ER_WRONG_MAGIC = 1389u16, + ER_PS_MANY_PARAM = 1390u16, + ER_KEY_PART_0 = 1391u16, + ER_VIEW_CHECKSUM = 1392u16, + ER_VIEW_MULTIUPDATE = 1393u16, + ER_VIEW_NO_INSERT_FIELD_LIST = 1394u16, + ER_VIEW_DELETE_MERGE_VIEW = 1395u16, + ER_CANNOT_USER = 1396u16, + ER_XAER_NOTA = 1397u16, + ER_XAER_INVAL = 1398u16, + ER_XAER_RMFAIL = 1399u16, + ER_XAER_OUTSIDE = 1400u16, + ER_XAER_RMERR = 1401u16, + ER_XA_RBROLLBACK = 1402u16, + ER_NONEXISTING_PROC_GRANT = 1403u16, + ER_PROC_AUTO_GRANT_FAIL = 1404u16, + ER_PROC_AUTO_REVOKE_FAIL = 1405u16, + ER_DATA_TOO_LONG = 1406u16, + ER_SP_BAD_SQLSTATE = 1407u16, + ER_STARTUP = 1408u16, + ER_LOAD_FROM_FIXED_SIZE_ROWS_TO_VAR = 1409u16, + ER_CANT_CREATE_USER_WITH_GRANT = 1410u16, + ER_WRONG_VALUE_FOR_TYPE = 1411u16, + ER_TABLE_DEF_CHANGED = 1412u16, + ER_SP_DUP_HANDLER = 1413u16, + ER_SP_NOT_VAR_ARG = 1414u16, + ER_SP_NO_RETSET = 1415u16, + ER_CANT_CREATE_GEOMETRY_OBJECT = 1416u16, + ER_FAILED_ROUTINE_BREAK_BINLOG = 1417u16, + ER_BINLOG_UNSAFE_ROUTINE = 1418u16, + ER_BINLOG_CREATE_ROUTINE_NEED_SUPER = 1419u16, + ER_EXEC_STMT_WITH_OPEN_CURSOR = 1420u16, + ER_STMT_HAS_NO_OPEN_CURSOR = 1421u16, + ER_COMMIT_NOT_ALLOWED_IN_SF_OR_TRG = 1422u16, + ER_NO_DEFAULT_FOR_VIEW_FIELD = 1423u16, + ER_SP_NO_RECURSION = 1424u16, + ER_TOO_BIG_SCALE = 1425u16, + ER_TOO_BIG_PRECISION = 1426u16, + ER_M_BIGGER_THAN_D = 1427u16, + ER_WRONG_LOCK_OF_SYSTEM_TABLE = 1428u16, + ER_CONNECT_TO_FOREIGN_DATA_SOURCE = 1429u16, + ER_QUERY_ON_FOREIGN_DATA_SOURCE = 1430u16, + ER_FOREIGN_DATA_SOURCE_DOESNT_EXIST = 1431u16, + ER_FOREIGN_DATA_STRING_INVALID_CANT_CREATE = 1432u16, + ER_FOREIGN_DATA_STRING_INVALID = 1433u16, + ER_CANT_CREATE_FEDERATED_TABLE = 1434u16, + ER_TRG_IN_WRONG_SCHEMA = 1435u16, + ER_STACK_OVERRUN_NEED_MORE = 1436u16, + ER_TOO_LONG_BODY = 1437u16, + ER_WARN_CANT_DROP_DEFAULT_KEYCACHE = 1438u16, + ER_TOO_BIG_DISPLAYWIDTH = 1439u16, + ER_XAER_DUPID = 1440u16, + ER_DATETIME_FUNCTION_OVERFLOW = 1441u16, + ER_CANT_UPDATE_USED_TABLE_IN_SF_OR_TRG = 1442u16, + ER_VIEW_PREVENT_UPDATE = 1443u16, + ER_PS_NO_RECURSION = 1444u16, + ER_SP_CANT_SET_AUTOCOMMIT = 1445u16, + ER_MALFORMED_DEFINER = 1446u16, + ER_VIEW_FRM_NO_USER = 1447u16, + ER_VIEW_OTHER_USER = 1448u16, + ER_NO_SUCH_USER = 1449u16, + ER_FORBID_SCHEMA_CHANGE = 1450u16, + ER_ROW_IS_REFERENCED_2 = 1451u16, + ER_NO_REFERENCED_ROW_2 = 1452u16, + ER_SP_BAD_VAR_SHADOW = 1453u16, + ER_TRG_NO_DEFINER = 1454u16, + ER_OLD_FILE_FORMAT = 1455u16, + ER_SP_RECURSION_LIMIT = 1456u16, + ER_SP_PROC_TABLE_CORRUPT = 1457u16, + ER_SP_WRONG_NAME = 1458u16, + ER_TABLE_NEEDS_UPGRADE = 1459u16, + ER_SP_NO_AGGREGATE = 1460u16, + ER_MAX_PREPARED_STMT_COUNT_REACHED = 1461u16, + ER_VIEW_RECURSIVE = 1462u16, + ER_NON_GROUPING_FIELD_USED = 1463u16, + ER_TABLE_CANT_HANDLE_SPKEYS = 1464u16, + ER_NO_TRIGGERS_ON_SYSTEM_SCHEMA = 1465u16, + ER_REMOVED_SPACES = 1466u16, + ER_AUTOINC_READ_FAILED = 1467u16, + ER_USERNAME = 1468u16, + ER_HOSTNAME = 1469u16, + ER_WRONG_STRING_LENGTH = 1470u16, + ER_NON_INSERTABLE_TABLE = 1471u16, + ER_ADMIN_WRONG_MRG_TABLE = 1472u16, + ER_TOO_HIGH_LEVEL_OF_NESTING_FOR_SELECT = 1473u16, + ER_NAME_BECOMES_EMPTY = 1474u16, + ER_AMBIGUOUS_FIELD_TERM = 1475u16, + ER_FOREIGN_SERVER_EXISTS = 1476u16, + ER_FOREIGN_SERVER_DOESNT_EXIST = 1477u16, + ER_ILLEGAL_HA_CREATE_OPTION = 1478u16, + ER_PARTITION_REQUIRES_VALUES_ERROR = 1479u16, + ER_PARTITION_WRONG_VALUES_ERROR = 1480u16, + ER_PARTITION_MAXVALUE_ERROR = 1481u16, + ER_PARTITION_SUBPARTITION_ERROR = 1482u16, + ER_PARTITION_SUBPART_MIX_ERROR = 1483u16, + ER_PARTITION_WRONG_NO_PART_ERROR = 1484u16, + ER_PARTITION_WRONG_NO_SUBPART_ERROR = 1485u16, + ER_CONST_EXPR_IN_PARTITION_FUNC_ERROR = 1486u16, + ER_NO_CONST_EXPR_IN_RANGE_OR_LIST_ERROR = 1487u16, + ER_FIELD_NOT_FOUND_PART_ERROR = 1488u16, + ER_LIST_OF_FIELDS_ONLY_IN_HASH_ERROR = 1489u16, + ER_INCONSISTENT_PARTITION_INFO_ERROR = 1490u16, + ER_PARTITION_FUNC_NOT_ALLOWED_ERROR = 1491u16, + ER_PARTITIONS_MUST_BE_DEFINED_ERROR = 1492u16, + ER_RANGE_NOT_INCREASING_ERROR = 1493u16, + ER_INCONSISTENT_TYPE_OF_FUNCTIONS_ERROR = 1494u16, + ER_MULTIPLE_DEF_CONST_IN_LIST_PART_ERROR = 1495u16, + ER_PARTITION_ENTRY_ERROR = 1496u16, + ER_MIX_HANDLER_ERROR = 1497u16, + ER_PARTITION_NOT_DEFINED_ERROR = 1498u16, + ER_TOO_MANY_PARTITIONS_ERROR = 1499u16, + ER_SUBPARTITION_ERROR = 1500u16, + ER_CANT_CREATE_HANDLER_FILE = 1501u16, + ER_BLOB_FIELD_IN_PART_FUNC_ERROR = 1502u16, + ER_UNIQUE_KEY_NEED_ALL_FIELDS_IN_PF = 1503u16, + ER_NO_PARTS_ERROR = 1504u16, + ER_PARTITION_MGMT_ON_NONPARTITIONED = 1505u16, + ER_FOREIGN_KEY_ON_PARTITIONED = 1506u16, + ER_DROP_PARTITION_NON_EXISTENT = 1507u16, + ER_DROP_LAST_PARTITION = 1508u16, + ER_COALESCE_ONLY_ON_HASH_PARTITION = 1509u16, + ER_REORG_HASH_ONLY_ON_SAME_NO = 1510u16, + ER_REORG_NO_PARAM_ERROR = 1511u16, + ER_ONLY_ON_RANGE_LIST_PARTITION = 1512u16, + ER_ADD_PARTITION_SUBPART_ERROR = 1513u16, + ER_ADD_PARTITION_NO_NEW_PARTITION = 1514u16, + ER_COALESCE_PARTITION_NO_PARTITION = 1515u16, + ER_REORG_PARTITION_NOT_EXIST = 1516u16, + ER_SAME_NAME_PARTITION = 1517u16, + ER_NO_BINLOG_ERROR = 1518u16, + ER_CONSECUTIVE_REORG_PARTITIONS = 1519u16, + ER_REORG_OUTSIDE_RANGE = 1520u16, + ER_PARTITION_FUNCTION_FAILURE = 1521u16, + ER_PART_STATE_ERROR = 1522u16, + ER_LIMITED_PART_RANGE = 1523u16, + ER_PLUGIN_IS_NOT_LOADED = 1524u16, + ER_WRONG_VALUE = 1525u16, + ER_NO_PARTITION_FOR_GIVEN_VALUE = 1526u16, + ER_FILEGROUP_OPTION_ONLY_ONCE = 1527u16, + ER_CREATE_FILEGROUP_FAILED = 1528u16, + ER_DROP_FILEGROUP_FAILED = 1529u16, + ER_TABLESPACE_AUTO_EXTEND_ERROR = 1530u16, + ER_WRONG_SIZE_NUMBER = 1531u16, + ER_SIZE_OVERFLOW_ERROR = 1532u16, + ER_ALTER_FILEGROUP_FAILED = 1533u16, + ER_BINLOG_ROW_LOGGING_FAILED = 1534u16, + ER_BINLOG_ROW_WRONG_TABLE_DEF = 1535u16, + ER_BINLOG_ROW_RBR_TO_SBR = 1536u16, + ER_EVENT_ALREADY_EXISTS = 1537u16, + ER_EVENT_STORE_FAILED = 1538u16, + ER_EVENT_DOES_NOT_EXIST = 1539u16, + ER_EVENT_CANT_ALTER = 1540u16, + ER_EVENT_DROP_FAILED = 1541u16, + ER_EVENT_INTERVAL_NOT_POSITIVE_OR_TOO_BIG = 1542u16, + ER_EVENT_ENDS_BEFORE_STARTS = 1543u16, + ER_EVENT_EXEC_TIME_IN_THE_PAST = 1544u16, + ER_EVENT_OPEN_TABLE_FAILED = 1545u16, + ER_EVENT_NEITHER_M_EXPR_NOR_M_AT = 1546u16, + ER_COL_COUNT_DOESNT_MATCH_CORRUPTED = 1547u16, + ER_CANNOT_LOAD_FROM_TABLE = 1548u16, + ER_EVENT_CANNOT_DELETE = 1549u16, + ER_EVENT_COMPILE_ERROR = 1550u16, + ER_EVENT_SAME_NAME = 1551u16, + ER_EVENT_DATA_TOO_LONG = 1552u16, + ER_DROP_INDEX_FK = 1553u16, + ER_WARN_DEPRECATED_SYNTAX_WITH_VER = 1554u16, + ER_CANT_WRITE_LOCK_LOG_TABLE = 1555u16, + ER_CANT_LOCK_LOG_TABLE = 1556u16, + ER_FOREIGN_DUPLICATE_KEY = 1557u16, + ER_COL_COUNT_DOESNT_MATCH_PLEASE_UPDATE = 1558u16, + ER_TEMP_TABLE_PREVENTS_SWITCH_OUT_OF_RBR = 1559u16, + ER_STORED_FUNCTION_PREVENTS_SWITCH_BINLOG_FORMAT = 1560u16, + ER_NDB_CANT_SWITCH_BINLOG_FORMAT = 1561u16, + ER_PARTITION_NO_TEMPORARY = 1562u16, + ER_PARTITION_CONST_DOMAIN_ERROR = 1563u16, + ER_PARTITION_FUNCTION_IS_NOT_ALLOWED = 1564u16, + ER_DDL_LOG_ERROR = 1565u16, + ER_NULL_IN_VALUES_LESS_THAN = 1566u16, + ER_WRONG_PARTITION_NAME = 1567u16, + ER_CANT_CHANGE_TX_ISOLATION = 1568u16, + ER_DUP_ENTRY_AUTOINCREMENT_CASE = 1569u16, + ER_EVENT_MODIFY_QUEUE_ERROR = 1570u16, + ER_EVENT_SET_VAR_ERROR = 1571u16, + ER_PARTITION_MERGE_ERROR = 1572u16, + ER_CANT_ACTIVATE_LOG = 1573u16, + ER_RBR_NOT_AVAILABLE = 1574u16, + ER_BASE64_DECODE_ERROR = 1575u16, + ER_EVENT_RECURSION_FORBIDDEN = 1576u16, + ER_EVENTS_DB_ERROR = 1577u16, + ER_ONLY_INTEGERS_ALLOWED = 1578u16, + ER_UNSUPORTED_LOG_ENGINE = 1579u16, + ER_BAD_LOG_STATEMENT = 1580u16, + ER_CANT_RENAME_LOG_TABLE = 1581u16, + ER_WRONG_PARAMCOUNT_TO_NATIVE_FCT = 1582u16, + ER_WRONG_PARAMETERS_TO_NATIVE_FCT = 1583u16, + ER_WRONG_PARAMETERS_TO_STORED_FCT = 1584u16, + ER_NATIVE_FCT_NAME_COLLISION = 1585u16, + ER_DUP_ENTRY_WITH_KEY_NAME = 1586u16, + ER_BINLOG_PURGE_EMFILE = 1587u16, + ER_EVENT_CANNOT_CREATE_IN_THE_PAST = 1588u16, + ER_EVENT_CANNOT_ALTER_IN_THE_PAST = 1589u16, + ER_SLAVE_INCIDENT = 1590u16, + ER_NO_PARTITION_FOR_GIVEN_VALUE_SILENT = 1591u16, + ER_BINLOG_UNSAFE_STATEMENT = 1592u16, + ER_SLAVE_FATAL_ERROR = 1593u16, + ER_SLAVE_RELAY_LOG_READ_FAILURE = 1594u16, + ER_SLAVE_RELAY_LOG_WRITE_FAILURE = 1595u16, + ER_SLAVE_CREATE_EVENT_FAILURE = 1596u16, + ER_SLAVE_MASTER_COM_FAILURE = 1597u16, + ER_BINLOG_LOGGING_IMPOSSIBLE = 1598u16, + ER_VIEW_NO_CREATION_CTX = 1599u16, + ER_VIEW_INVALID_CREATION_CTX = 1600u16, + ER_SR_INVALID_CREATION_CTX = 1601u16, + ER_TRG_CORRUPTED_FILE = 1602u16, + ER_TRG_NO_CREATION_CTX = 1603u16, + ER_TRG_INVALID_CREATION_CTX = 1604u16, + ER_EVENT_INVALID_CREATION_CTX = 1605u16, + ER_TRG_CANT_OPEN_TABLE = 1606u16, + ER_CANT_CREATE_SROUTINE = 1607u16, + ER_SLAVE_AMBIGOUS_EXEC_MODE = 1608u16, + ER_NO_FORMAT_DESCRIPTION_EVENT_BEFORE_BINLOG_STATEMENT = 1609u16, + ER_SLAVE_CORRUPT_EVENT = 1610u16, + ER_LOAD_DATA_INVALID_COLUMN = 1611u16, + ER_LOG_PURGE_NO_FILE = 1612u16, + ER_XA_RBTIMEOUT = 1613u16, + ER_XA_RBDEADLOCK = 1614u16, + ER_NEED_REPREPARE = 1615u16, + ER_DELAYED_NOT_SUPPORTED = 1616u16, + WARN_NO_MASTER_INFO = 1617u16, + WARN_OPTION_IGNORED = 1618u16, + WARN_PLUGIN_DELETE_BUILTIN = 1619u16, + WARN_PLUGIN_BUSY = 1620u16, + ER_VARIABLE_IS_READONLY = 1621u16, + ER_WARN_ENGINE_TRANSACTION_ROLLBACK = 1622u16, + ER_SLAVE_HEARTBEAT_FAILURE = 1623u16, + ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE = 1624u16, + ER_NDB_REPLICATION_SCHEMA_ERROR = 1625u16, + ER_CONFLICT_FN_PARSE_ERROR = 1626u16, + ER_EXCEPTIONS_WRITE_ERROR = 1627u16, + ER_TOO_LONG_TABLE_COMMENT = 1628u16, + ER_TOO_LONG_FIELD_COMMENT = 1629u16, + ER_FUNC_INEXISTENT_NAME_COLLISION = 1630u16, + ER_DATABASE_NAME = 1631u16, + ER_TABLE_NAME = 1632u16, + ER_PARTITION_NAME = 1633u16, + ER_SUBPARTITION_NAME = 1634u16, + ER_TEMPORARY_NAME = 1635u16, + ER_RENAMED_NAME = 1636u16, + ER_TOO_MANY_CONCURRENT_TRXS = 1637u16, + WARN_NON_ASCII_SEPARATOR_NOT_IMPLEMENTED = 1638u16, + ER_DEBUG_SYNC_TIMEOUT = 1639u16, + ER_DEBUG_SYNC_HIT_LIMIT = 1640u16, + ER_DUP_SIGNAL_SET = 1641u16, + ER_SIGNAL_WARN = 1642u16, + ER_SIGNAL_NOT_FOUND = 1643u16, + ER_SIGNAL_EXCEPTION = 1644u16, + ER_RESIGNAL_WITHOUT_ACTIVE_HANDLER = 1645u16, + ER_SIGNAL_BAD_CONDITION_TYPE = 1646u16, + WARN_COND_ITEM_TRUNCATED = 1647u16, + ER_COND_ITEM_TOO_LONG = 1648u16, + ER_UNKNOWN_LOCALE = 1649u16, + ER_SLAVE_IGNORE_SERVER_IDS = 1650u16, + ER_QUERY_CACHE_DISABLED = 1651u16, + ER_SAME_NAME_PARTITION_FIELD = 1652u16, + ER_PARTITION_COLUMN_LIST_ERROR = 1653u16, + ER_WRONG_TYPE_COLUMN_VALUE_ERROR = 1654u16, + ER_TOO_MANY_PARTITION_FUNC_FIELDS_ERROR = 1655u16, + ER_MAXVALUE_IN_VALUES_IN = 1656u16, + ER_TOO_MANY_VALUES_ERROR = 1657u16, + ER_ROW_SINGLE_PARTITION_FIELD_ERROR = 1658u16, + ER_FIELD_TYPE_NOT_ALLOWED_AS_PARTITION_FIELD = 1659u16, + ER_PARTITION_FIELDS_TOO_LONG = 1660u16, + ER_BINLOG_ROW_ENGINE_AND_STMT_ENGINE = 1661u16, + ER_BINLOG_ROW_MODE_AND_STMT_ENGINE = 1662u16, + ER_BINLOG_UNSAFE_AND_STMT_ENGINE = 1663u16, + ER_BINLOG_ROW_INJECTION_AND_STMT_ENGINE = 1664u16, + ER_BINLOG_STMT_MODE_AND_ROW_ENGINE = 1665u16, + ER_BINLOG_ROW_INJECTION_AND_STMT_MODE = 1666u16, + ER_BINLOG_MULTIPLE_ENGINES_AND_SELF_LOGGING_ENGINE = 1667u16, + ER_BINLOG_UNSAFE_LIMIT = 1668u16, + ER_BINLOG_UNSAFE_INSERT_DELAYED = 1669u16, + ER_BINLOG_UNSAFE_SYSTEM_TABLE = 1670u16, + ER_BINLOG_UNSAFE_AUTOINC_COLUMNS = 1671u16, + ER_BINLOG_UNSAFE_UDF = 1672u16, + ER_BINLOG_UNSAFE_SYSTEM_VARIABLE = 1673u16, + ER_BINLOG_UNSAFE_SYSTEM_FUNCTION = 1674u16, + ER_BINLOG_UNSAFE_NONTRANS_AFTER_TRANS = 1675u16, + ER_MESSAGE_AND_STATEMENT = 1676u16, + ER_SLAVE_CONVERSION_FAILED = 1677u16, + ER_SLAVE_CANT_CREATE_CONVERSION = 1678u16, + ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_BINLOG_FORMAT = 1679u16, + ER_PATH_LENGTH = 1680u16, + ER_WARN_DEPRECATED_SYNTAX_NO_REPLACEMENT = 1681u16, + ER_WRONG_NATIVE_TABLE_STRUCTURE = 1682u16, + ER_WRONG_PERFSCHEMA_USAGE = 1683u16, + ER_WARN_I_S_SKIPPED_TABLE = 1684u16, + ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_BINLOG_DIRECT = 1685u16, + ER_STORED_FUNCTION_PREVENTS_SWITCH_BINLOG_DIRECT = 1686u16, + ER_SPATIAL_MUST_HAVE_GEOM_COL = 1687u16, + ER_TOO_LONG_INDEX_COMMENT = 1688u16, + ER_LOCK_ABORTED = 1689u16, + ER_DATA_OUT_OF_RANGE = 1690u16, + ER_WRONG_SPVAR_TYPE_IN_LIMIT = 1691u16, + ER_BINLOG_UNSAFE_MULTIPLE_ENGINES_AND_SELF_LOGGING_ENGINE = 1692u16, + ER_BINLOG_UNSAFE_MIXED_STATEMENT = 1693u16, + ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_SQL_LOG_BIN = 1694u16, + ER_STORED_FUNCTION_PREVENTS_SWITCH_SQL_LOG_BIN = 1695u16, + ER_FAILED_READ_FROM_PAR_FILE = 1696u16, + ER_VALUES_IS_NOT_INT_TYPE_ERROR = 1697u16, + ER_ACCESS_DENIED_NO_PASSWORD_ERROR = 1698u16, + ER_SET_PASSWORD_AUTH_PLUGIN = 1699u16, + ER_GRANT_PLUGIN_USER_EXISTS = 1700u16, + ER_TRUNCATE_ILLEGAL_FK = 1701u16, + ER_PLUGIN_IS_PERMANENT = 1702u16, + ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE_MIN = 1703u16, + ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE_MAX = 1704u16, + ER_STMT_CACHE_FULL = 1705u16, + ER_MULTI_UPDATE_KEY_CONFLICT = 1706u16, + ER_TABLE_NEEDS_REBUILD = 1707u16, + WARN_OPTION_BELOW_LIMIT = 1708u16, + ER_INDEX_COLUMN_TOO_LONG = 1709u16, + ER_ERROR_IN_TRIGGER_BODY = 1710u16, + ER_ERROR_IN_UNKNOWN_TRIGGER_BODY = 1711u16, + ER_INDEX_CORRUPT = 1712u16, + ER_UNDO_RECORD_TOO_BIG = 1713u16, + ER_BINLOG_UNSAFE_INSERT_IGNORE_SELECT = 1714u16, + ER_BINLOG_UNSAFE_INSERT_SELECT_UPDATE = 1715u16, + ER_BINLOG_UNSAFE_REPLACE_SELECT = 1716u16, + ER_BINLOG_UNSAFE_CREATE_IGNORE_SELECT = 1717u16, + ER_BINLOG_UNSAFE_CREATE_REPLACE_SELECT = 1718u16, + ER_BINLOG_UNSAFE_UPDATE_IGNORE = 1719u16, + ER_PLUGIN_NO_UNINSTALL = 1720u16, + ER_PLUGIN_NO_INSTALL = 1721u16, + ER_BINLOG_UNSAFE_WRITE_AUTOINC_SELECT = 1722u16, + ER_BINLOG_UNSAFE_CREATE_SELECT_AUTOINC = 1723u16, + ER_BINLOG_UNSAFE_INSERT_TWO_KEYS = 1724u16, + ER_TABLE_IN_FK_CHECK = 1725u16, + ER_UNSUPPORTED_ENGINE = 1726u16, + ER_BINLOG_UNSAFE_AUTOINC_NOT_FIRST = 1727u16, +} diff --git a/vendor/mysql-28.0.0/src/error/tls/mod.rs b/vendor/mysql-28.0.0/src/error/tls/mod.rs new file mode 100644 index 0000000000..3ce898f593 --- /dev/null +++ b/vendor/mysql-28.0.0/src/error/tls/mod.rs @@ -0,0 +1,26 @@ +#![cfg(any(feature = "native-tls", feature = "rustls"))] + +mod native_tls_error; +mod rustls_error; + +#[cfg(feature = "native-tls")] +#[cfg_attr( + docsrs, + doc(cfg(any( + feature = "native-tls", + feature = "rustls-tls", + feature = "rustls-tls-ring" + ))) +)] +pub use native_tls_error::TlsError; + +#[cfg(feature = "rustls")] +#[cfg_attr( + docsrs, + doc(cfg(any( + feature = "native-tls", + feature = "rustls-tls", + feature = "rustls-tls-ring" + ))) +)] +pub use rustls_error::TlsError; diff --git a/vendor/mysql-28.0.0/src/error/tls/native_tls_error.rs b/vendor/mysql-28.0.0/src/error/tls/native_tls_error.rs new file mode 100644 index 0000000000..3a21e1871c --- /dev/null +++ b/vendor/mysql-28.0.0/src/error/tls/native_tls_error.rs @@ -0,0 +1,45 @@ +#![cfg(feature = "native-tls")] + +use std::fmt::Display; + +#[derive(Debug)] +pub enum TlsError { + TlsError(native_tls::Error), + TlsHandshakeError(native_tls::HandshakeError), +} + +impl From for super::super::Error { + fn from(err: TlsError) -> super::super::Error { + super::super::Error::TlsError(err) + } +} + +impl From for super::super::Error { + fn from(err: native_tls::Error) -> super::super::Error { + super::super::Error::TlsError(TlsError::TlsError(err)) + } +} + +impl From> for super::super::Error { + fn from(err: native_tls::HandshakeError) -> super::super::Error { + super::super::Error::TlsError(TlsError::TlsHandshakeError(err)) + } +} + +impl std::error::Error for TlsError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + TlsError::TlsError(e) => Some(e), + TlsError::TlsHandshakeError(e) => Some(e), + } + } +} + +impl Display for TlsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TlsError::TlsError(e) => e.fmt(f), + TlsError::TlsHandshakeError(e) => e.fmt(f), + } + } +} diff --git a/vendor/mysql-28.0.0/src/error/tls/rustls_error.rs b/vendor/mysql-28.0.0/src/error/tls/rustls_error.rs new file mode 100644 index 0000000000..96c1ebff35 --- /dev/null +++ b/vendor/mysql-28.0.0/src/error/tls/rustls_error.rs @@ -0,0 +1,83 @@ +#![cfg(feature = "rustls")] + +use std::fmt::Display; + +use rustls::server::VerifierBuilderError; + +#[derive(Debug)] +pub enum TlsError { + VerifierBuilderError(VerifierBuilderError), + Tls(rustls::Error), + Pki(webpki::Error), + InvalidDnsName(webpki::InvalidDnsNameError), +} + +impl From for crate::Error { + fn from(e: TlsError) -> Self { + crate::Error::TlsError(e) + } +} + +impl From for TlsError { + fn from(e: VerifierBuilderError) -> Self { + TlsError::VerifierBuilderError(e) + } +} + +impl From for TlsError { + fn from(e: rustls::Error) -> Self { + TlsError::Tls(e) + } +} + +impl From for TlsError { + fn from(e: webpki::InvalidDnsNameError) -> Self { + TlsError::InvalidDnsName(e) + } +} + +impl From for TlsError { + fn from(e: webpki::Error) -> Self { + TlsError::Pki(e) + } +} + +impl From for crate::Error { + fn from(e: rustls::Error) -> Self { + crate::Error::TlsError(e.into()) + } +} + +impl From for crate::Error { + fn from(e: webpki::Error) -> Self { + crate::Error::TlsError(e.into()) + } +} + +impl From for crate::Error { + fn from(e: webpki::InvalidDnsNameError) -> Self { + crate::Error::TlsError(e.into()) + } +} + +impl std::error::Error for TlsError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + TlsError::VerifierBuilderError(e) => Some(e), + TlsError::Tls(e) => Some(e), + TlsError::Pki(e) => Some(e), + TlsError::InvalidDnsName(e) => Some(e), + } + } +} + +impl Display for TlsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TlsError::VerifierBuilderError(e) => e.fmt(f), + TlsError::Tls(e) => e.fmt(f), + TlsError::Pki(e) => e.fmt(f), + TlsError::InvalidDnsName(e) => e.fmt(f), + } + } +} diff --git a/vendor/mysql-28.0.0/src/io/mod.rs b/vendor/mysql-28.0.0/src/io/mod.rs new file mode 100644 index 0000000000..63e2577895 --- /dev/null +++ b/vendor/mysql-28.0.0/src/io/mod.rs @@ -0,0 +1,197 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use bufstream::BufStream; +use io_enum::*; +#[cfg(windows)] +use named_pipe as np; + +#[cfg(unix)] +use std::os::{ + unix, + unix::io::{AsRawFd, RawFd}, +}; +use std::{ + fmt, io, + net::{self, SocketAddr}, + time::Duration, +}; + +use crate::error::{ + DriverError::{ConnectTimeout, CouldNotConnect}, + Error::DriverError, + Result as MyResult, +}; + +mod tcp; +mod tls; + +#[derive(Debug, Read, Write)] +pub enum Stream { + #[cfg(unix)] + SocketStream(BufStream), + #[cfg(windows)] + SocketStream(BufStream), + TcpStream(TcpStream), +} + +impl Stream { + #[cfg(unix)] + pub fn connect_socket( + socket: &str, + read_timeout: Option, + write_timeout: Option, + ) -> MyResult { + match unix::net::UnixStream::connect(socket) { + Ok(stream) => { + stream.set_read_timeout(read_timeout)?; + stream.set_write_timeout(write_timeout)?; + Ok(Stream::SocketStream(BufStream::new(stream))) + } + Err(e) => { + let addr = socket.to_string(); + let desc = e.to_string(); + Err(DriverError(CouldNotConnect(Some((addr, desc, e.kind()))))) + } + } + } + + #[cfg(windows)] + pub fn connect_socket( + socket: &str, + read_timeout: Option, + write_timeout: Option, + ) -> MyResult { + let full_name = format!(r"\\.\pipe\{}", socket); + match np::PipeClient::connect(full_name.clone()) { + Ok(mut stream) => { + stream.set_read_timeout(read_timeout); + stream.set_write_timeout(write_timeout); + Ok(Stream::SocketStream(BufStream::new(stream))) + } + Err(e) => { + let desc = format!("{}", e); + Err(DriverError(CouldNotConnect(Some(( + full_name, + desc, + e.kind(), + ))))) + } + } + } + + #[cfg(all(not(unix), not(windows)))] + fn connect_socket(&mut self) -> MyResult<()> { + unimplemented!("Sockets is not implemented on current platform"); + } + + #[allow(clippy::too_many_arguments)] + pub fn connect_tcp( + ip_or_hostname: &str, + port: u16, + read_timeout: Option, + write_timeout: Option, + tcp_keepalive_time: Option, + #[cfg(any(target_os = "linux", target_os = "macos",))] + tcp_keepalive_probe_interval_secs: Option, + #[cfg(any(target_os = "linux", target_os = "macos",))] tcp_keepalive_probe_count: Option< + u32, + >, + #[cfg(target_os = "linux")] tcp_user_timeout: Option, + nodelay: bool, + tcp_connect_timeout: Option, + bind_address: Option, + ) -> MyResult { + let mut builder = tcp::MyTcpBuilder::new((ip_or_hostname, port)); + builder + .connect_timeout(tcp_connect_timeout) + .read_timeout(read_timeout) + .write_timeout(write_timeout) + .keepalive_time_ms(tcp_keepalive_time) + .nodelay(nodelay) + .bind_address(bind_address); + #[cfg(any(target_os = "linux", target_os = "macos",))] + builder.keepalive_probe_interval_secs(tcp_keepalive_probe_interval_secs); + #[cfg(any(target_os = "linux", target_os = "macos",))] + builder.keepalive_probe_count(tcp_keepalive_probe_count); + #[cfg(target_os = "linux")] + builder.user_timeout(tcp_user_timeout); + builder + .connect() + .map(|stream| Stream::TcpStream(TcpStream::Insecure(BufStream::new(stream)))) + .map_err(|err| { + if err.kind() == io::ErrorKind::TimedOut { + DriverError(ConnectTimeout) + } else { + let addr = format!("{}:{}", ip_or_hostname, port); + let desc = format!("{}", err); + DriverError(CouldNotConnect(Some((addr, desc, err.kind())))) + } + }) + } + + pub fn is_insecure(&self) -> bool { + matches!(self, Stream::TcpStream(TcpStream::Insecure(_))) + } + + pub fn is_socket(&self) -> bool { + matches!(self, Stream::SocketStream(_)) + } + + #[cfg(all(not(feature = "native-tls"), not(feature = "rustls")))] + pub fn make_secure(self, _host: url::Host, _ssl_opts: crate::SslOpts) -> MyResult { + panic!( + "Client had asked for TLS connection but TLS support is disabled. \ + Please enable one of the following features: \"native-tls\", \"rustls-tls\", \"rustls-tls-ring\"" + ) + } +} + +#[cfg(unix)] +impl AsRawFd for Stream { + fn as_raw_fd(&self) -> RawFd { + match self { + Stream::SocketStream(stream) => stream.get_ref().as_raw_fd(), + Stream::TcpStream(stream) => stream.as_raw_fd(), + } + } +} + +#[derive(Read, Write)] +pub enum TcpStream { + #[cfg(feature = "native-tls")] + Secure(BufStream>), + #[cfg(feature = "rustls")] + Secure(BufStream>>), + Insecure(BufStream), +} + +#[cfg(unix)] +impl AsRawFd for TcpStream { + fn as_raw_fd(&self) -> RawFd { + match self { + #[cfg(feature = "native-tls")] + TcpStream::Secure(stream) => stream.get_ref().get_ref().as_raw_fd(), + #[cfg(feature = "rustls")] + TcpStream::Secure(stream) => stream.get_ref().get_ref().as_raw_fd(), + TcpStream::Insecure(stream) => stream.get_ref().as_raw_fd(), + } + } +} + +impl fmt::Debug for TcpStream { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + #[cfg(feature = "native-tls")] + TcpStream::Secure(ref s) => write!(f, "Secure stream {:?}", s), + #[cfg(feature = "rustls")] + TcpStream::Secure(ref s) => write!(f, "Secure stream {:?}", s), + TcpStream::Insecure(ref s) => write!(f, "Insecure stream {:?}", s), + } + } +} diff --git a/vendor/mysql-28.0.0/src/io/tcp.rs b/vendor/mysql-28.0.0/src/io/tcp.rs new file mode 100644 index 0000000000..48c954a747 --- /dev/null +++ b/vendor/mysql-28.0.0/src/io/tcp.rs @@ -0,0 +1,243 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +use socket2::{Domain, SockAddr, Socket, Type}; + +use std::{ + io, + net::{SocketAddr, TcpStream, ToSocketAddrs}, + time::Duration, +}; + +pub struct MyTcpBuilder { + address: T, + bind_address: Option, + connect_timeout: Option, + read_timeout: Option, + write_timeout: Option, + keepalive_time_ms: Option, + #[cfg(any(target_os = "linux", target_os = "macos",))] + keepalive_probe_interval_secs: Option, + #[cfg(any(target_os = "linux", target_os = "macos",))] + keepalive_probe_count: Option, + #[cfg(target_os = "linux")] + user_timeout: Option, + nodelay: bool, +} + +impl MyTcpBuilder { + pub fn keepalive_time_ms(&mut self, keepalive_time_ms: Option) -> &mut Self { + self.keepalive_time_ms = keepalive_time_ms; + self + } + + #[cfg(any(target_os = "linux", target_os = "macos",))] + pub fn keepalive_probe_interval_secs( + &mut self, + keepalive_probe_interval_secs: Option, + ) -> &mut Self { + self.keepalive_probe_interval_secs = keepalive_probe_interval_secs; + self + } + + #[cfg(any(target_os = "linux", target_os = "macos",))] + pub fn keepalive_probe_count(&mut self, keepalive_probe_count: Option) -> &mut Self { + self.keepalive_probe_count = keepalive_probe_count; + self + } + + #[cfg(target_os = "linux")] + pub fn user_timeout(&mut self, user_timeout: Option) -> &mut Self { + self.user_timeout = user_timeout; + self + } + + pub fn nodelay(&mut self, nodelay: bool) -> &mut Self { + self.nodelay = nodelay; + self + } + + pub fn write_timeout(&mut self, write_timeout: Option) -> &mut Self { + self.write_timeout = write_timeout; + self + } + + pub fn read_timeout(&mut self, read_timeout: Option) -> &mut Self { + self.read_timeout = read_timeout; + self + } + + pub fn bind_address(&mut self, bind_address: Option) -> &mut Self + where + U: Into, + { + self.bind_address = bind_address.map(Into::into); + self + } + + pub fn connect_timeout(&mut self, timeout: Option) -> &mut Self { + self.connect_timeout = timeout; + self + } + + pub fn new(address: T) -> MyTcpBuilder { + MyTcpBuilder { + address, + bind_address: None, + connect_timeout: None, + read_timeout: None, + write_timeout: None, + keepalive_time_ms: None, + #[cfg(any(target_os = "linux", target_os = "macos",))] + keepalive_probe_interval_secs: None, + #[cfg(any(target_os = "linux", target_os = "macos",))] + keepalive_probe_count: None, + #[cfg(target_os = "linux")] + user_timeout: None, + nodelay: true, + } + } + + pub fn connect(self) -> io::Result { + let MyTcpBuilder { + address, + bind_address, + connect_timeout, + read_timeout, + write_timeout, + keepalive_time_ms, + #[cfg(any(target_os = "linux", target_os = "macos"))] + keepalive_probe_interval_secs, + #[cfg(any(target_os = "linux", target_os = "macos",))] + keepalive_probe_count, + #[cfg(target_os = "linux")] + user_timeout, + nodelay, + } = self; + let err_msg = if bind_address.is_none() { + "could not connect to any address" + } else { + "could not connect to any address with specified bind address" + }; + let err = io::Error::other(err_msg); + + let addrs = address.to_socket_addrs()?.collect::>(); + + let socket = if let Some(bind_address) = bind_address { + let fold_fun = |prev, sock_addr: &SocketAddr| match prev { + Ok(socket) => Ok(socket), + Err(_) => { + let domain = Domain::for_address(*sock_addr); + let socket = Socket::new(domain, Type::STREAM, None)?; + socket.bind(&bind_address.into())?; + if let Some(connect_timeout) = connect_timeout { + socket.connect_timeout(&SockAddr::from(*sock_addr), connect_timeout)?; + } else { + socket.connect(&SockAddr::from(*sock_addr))?; + } + Ok(socket) + } + }; + + if bind_address.is_ipv4() { + // client wants to bind to ipv4, so let's look for ipv4 addresses first + addrs + .iter() + .filter(|x| x.is_ipv4()) + .fold(Err(err), fold_fun) + .or_else(|e| addrs.iter().filter(|x| x.is_ipv6()).fold(Err(e), fold_fun)) + } else { + // client wants to bind to ipv6, so let's look for ipv6 addresses first + addrs + .iter() + .filter(|x| x.is_ipv6()) + .fold(Err(err), fold_fun) + .or_else(|e| addrs.iter().filter(|x| x.is_ipv4()).fold(Err(e), fold_fun)) + } + } else { + // no bind address + addrs + .into_iter() + .try_fold(None, |prev, sock_addr| match prev { + Some(x) => io::Result::Ok(Some(x)), + None => { + let domain = Domain::for_address(sock_addr); + let socket = Socket::new(domain, Type::STREAM, None)?; + if let Some(connect_timeout) = connect_timeout { + socket.connect_timeout(&sock_addr.into(), connect_timeout)?; + } else { + socket.connect(&sock_addr.into())?; + } + Ok(Some(socket)) + } + })? + .ok_or(err) + }?; + + socket.set_read_timeout(read_timeout)?; + socket.set_write_timeout(write_timeout)?; + if let Some(duration) = keepalive_time_ms { + let conf = + socket2::TcpKeepalive::new().with_time(Duration::from_millis(duration as u64)); + socket.set_tcp_keepalive(&conf)?; + } + #[cfg(any(target_os = "linux", target_os = "macos",))] + if let Some(keepalive_probe_interval_secs) = keepalive_probe_interval_secs { + use std::os::unix::io::AsRawFd; + let fd = socket.as_raw_fd(); + unsafe { + if libc::setsockopt( + fd, + libc::IPPROTO_TCP, + libc::TCP_KEEPINTVL, + &keepalive_probe_interval_secs as *const _ as *const libc::c_void, + std::mem::size_of_val(&keepalive_probe_interval_secs) as libc::socklen_t, + ) != 0 + { + return Err(io::Error::last_os_error()); + } + } + } + #[cfg(any(target_os = "linux", target_os = "macos"))] + if let Some(keepalive_probe_count) = keepalive_probe_count { + use std::os::unix::io::AsRawFd; + let fd = socket.as_raw_fd(); + unsafe { + if libc::setsockopt( + fd, + libc::IPPROTO_TCP, + libc::TCP_KEEPCNT, + &keepalive_probe_count as *const _ as *const libc::c_void, + std::mem::size_of_val(&keepalive_probe_count) as libc::socklen_t, + ) != 0 + { + return Err(io::Error::last_os_error()); + } + } + } + #[cfg(target_os = "linux")] + if let Some(timeout) = user_timeout { + use std::os::unix::io::AsRawFd; + let fd = socket.as_raw_fd(); + unsafe { + if libc::setsockopt( + fd, + libc::SOL_TCP, + libc::TCP_USER_TIMEOUT, + &timeout as *const _ as *const libc::c_void, + std::mem::size_of_val(&timeout) as libc::socklen_t, + ) != 0 + { + return Err(io::Error::last_os_error()); + } + } + } + socket.set_tcp_nodelay(nodelay)?; + Ok(TcpStream::from(socket)) + } +} diff --git a/vendor/mysql-28.0.0/src/io/tls/mod.rs b/vendor/mysql-28.0.0/src/io/tls/mod.rs new file mode 100644 index 0000000000..92f5e7c2d5 --- /dev/null +++ b/vendor/mysql-28.0.0/src/io/tls/mod.rs @@ -0,0 +1,4 @@ +#![cfg(any(feature = "native-tls", feature = "rustls"))] + +mod native_tls_io; +mod rustls_io; diff --git a/vendor/mysql-28.0.0/src/io/tls/native_tls_io.rs b/vendor/mysql-28.0.0/src/io/tls/native_tls_io.rs new file mode 100644 index 0000000000..6768bdef41 --- /dev/null +++ b/vendor/mysql-28.0.0/src/io/tls/native_tls_io.rs @@ -0,0 +1,71 @@ +#![cfg(feature = "native-tls")] + +use std::{ + fs::File, + io::{self, Read}, +}; + +use bufstream::BufStream; +use native_tls::{Certificate, TlsConnector}; + +use crate::{ + io::{Stream, TcpStream}, + Result, SslOpts, +}; + +impl Stream { + pub fn make_secure(self, host: url::Host, ssl_opts: SslOpts) -> Result { + if self.is_socket() { + // won't secure socket connection + return Ok(self); + } + + let domain = match host { + url::Host::Domain(domain) => domain, + url::Host::Ipv4(ip) => ip.to_string(), + url::Host::Ipv6(ip) => ip.to_string(), + }; + + let mut builder = TlsConnector::builder(); + if let Some(root_cert_path) = ssl_opts.root_cert_path() { + let mut root_cert_data = vec![]; + let mut root_cert_file = File::open(root_cert_path)?; + root_cert_file.read_to_end(&mut root_cert_data)?; + + let root_certs = Certificate::from_der(&root_cert_data) + .map(|x| vec![x]) + .or_else(|_| { + pem::parse_many(&*root_cert_data) + .unwrap_or_default() + .iter() + .map(pem::encode) + .map(|s| Certificate::from_pem(s.as_bytes())) + .collect() + })?; + + for root_cert in root_certs { + builder.add_root_certificate(root_cert); + } + } + if let Some(client_identity) = ssl_opts.client_identity() { + let identity = client_identity.load()?; + builder.identity(identity); + } + builder.danger_accept_invalid_hostnames(ssl_opts.skip_domain_validation()); + builder.danger_accept_invalid_certs(ssl_opts.accept_invalid_certs()); + let tls_connector = builder.build()?; + match self { + Stream::TcpStream(tcp_stream) => match tcp_stream { + TcpStream::Insecure(insecure_stream) => { + let inner = insecure_stream.into_inner().map_err(io::Error::from)?; + let secure_stream = tls_connector.connect(&domain, inner)?; + Ok(Stream::TcpStream(TcpStream::Secure(BufStream::new( + secure_stream, + )))) + } + TcpStream::Secure(_) => Ok(Stream::TcpStream(tcp_stream)), + }, + _ => unreachable!(), + } + } +} diff --git a/vendor/mysql-28.0.0/src/io/tls/rustls_io.rs b/vendor/mysql-28.0.0/src/io/tls/rustls_io.rs new file mode 100644 index 0000000000..5a758a2689 --- /dev/null +++ b/vendor/mysql-28.0.0/src/io/tls/rustls_io.rs @@ -0,0 +1,207 @@ +#![cfg(feature = "rustls")] + +use std::{ + fs::File, + io::{self, Read}, + sync::Arc, +}; + +use bufstream::BufStream; +use rustls::{ + client::{ + danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + WebPkiServerVerifier, + }, + pki_types::{CertificateDer, ServerName, UnixTime}, + CertificateError, ClientConfig, Error, RootCertStore, SignatureScheme, +}; +use rustls_pemfile::certs; + +use crate::{ + error::tls::TlsError, + io::{Stream, TcpStream}, + Result, SslOpts, +}; + +impl Stream { + pub fn make_secure(self, host: url::Host, ssl_opts: SslOpts) -> Result { + if self.is_socket() { + // won't secure socket connection + return Ok(self); + } + + let domain = match host { + url::Host::Domain(domain) => domain, + url::Host::Ipv4(ip) => ip.to_string(), + url::Host::Ipv6(ip) => ip.to_string(), + }; + + let mut root_store = RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().map(|x| x.to_owned())); + + if let Some(root_cert_path) = ssl_opts.root_cert_path() { + let mut root_cert_data = vec![]; + let mut root_cert_file = File::open(root_cert_path)?; + root_cert_file.read_to_end(&mut root_cert_data)?; + + let mut root_certs = Vec::new(); + for cert in certs(&mut &*root_cert_data) { + root_certs.push(cert?); + } + + if root_certs.is_empty() && !root_cert_data.is_empty() { + root_certs.push(CertificateDer::from(root_cert_data)); + } + + for cert in &root_certs { + root_store.add(cert.to_owned())?; + } + } + + let mut provider = rustls::crypto::ring::default_provider(); + if let Some(requested) = ssl_opts.cipher_suites() { + provider.cipher_suites.retain(|suite| { + let iana = format!("{:?}", suite.suite()); + requested.iter().any(|name| cipher_name_matches(name, &iana)) + }); + if provider.cipher_suites.is_empty() { + return Err(TlsError::Tls(rustls::Error::General( + "no requested MySQL TLS cipher is supported by rustls".into(), + )).into()); + } + } + let config_builder = ClientConfig::builder_with_provider(Arc::new(provider)) + .with_safe_default_protocol_versions() + .map_err(TlsError::from)? + .with_root_certificates(root_store.clone()); + + let mut config = if let Some(identity) = ssl_opts.client_identity() { + let (cert_chain, priv_key) = identity.load()?; + config_builder.with_client_auth_cert(cert_chain, priv_key)? + } else { + config_builder.with_no_client_auth() + }; + + let server_name = ServerName::try_from(domain.as_str()) + .map_err(|_| webpki::InvalidDnsNameError)? + .to_owned(); + let mut dangerous = config.dangerous(); + let web_pki_verifier = WebPkiServerVerifier::builder(Arc::new(root_store)) + .build() + .map_err(TlsError::from)?; + let dangerous_verifier = DangerousVerifier::new( + ssl_opts.accept_invalid_certs(), + ssl_opts.skip_domain_validation(), + web_pki_verifier, + ); + dangerous.set_certificate_verifier(Arc::new(dangerous_verifier)); + + match self { + Stream::TcpStream(tcp_stream) => match tcp_stream { + TcpStream::Insecure(insecure_stream) => { + let inner = insecure_stream + .into_inner() + .map_err(io::Error::from) + .unwrap(); + let conn = + rustls::ClientConnection::new(Arc::new(config), server_name).unwrap(); + let secure_stream = rustls::StreamOwned::new(conn, inner); + Ok(Stream::TcpStream(TcpStream::Secure(BufStream::new( + Box::new(secure_stream), + )))) + } + TcpStream::Secure(_) => Ok(Stream::TcpStream(tcp_stream)), + }, + _ => unreachable!(), + } + } +} + +/// Matches IANA rustls suite names and the OpenSSL spellings accepted by MySQL. +fn cipher_name_matches(requested: &str, iana: &str) -> bool { + let requested = requested.trim().to_ascii_uppercase().replace('-', "_"); + let aliases = match iana { + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" => "ECDHE_RSA_AES128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" => "ECDHE_RSA_AES256_GCM_SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" => "ECDHE_RSA_CHACHA20_POLY1305", + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" => "ECDHE_ECDSA_AES128_GCM_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" => "ECDHE_ECDSA_AES256_GCM_SHA384", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" => "ECDHE_ECDSA_CHACHA20_POLY1305", + _ => iana, + }; + requested == iana || requested == aliases +} + +#[derive(Debug)] +struct DangerousVerifier { + accept_invalid_certs: bool, + skip_domain_validation: bool, + verifier: Arc, +} + +impl DangerousVerifier { + fn new( + accept_invalid_certs: bool, + skip_domain_validation: bool, + verifier: Arc, + ) -> Self { + Self { + accept_invalid_certs, + skip_domain_validation, + verifier, + } + } +} + +impl ServerCertVerifier for DangerousVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + if self.accept_invalid_certs { + Ok(ServerCertVerified::assertion()) + } else { + match self.verifier.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + ) { + Ok(assertion) => Ok(assertion), + Err(Error::InvalidCertificate(CertificateError::NotValidForName)) + if self.skip_domain_validation => + { + Ok(ServerCertVerified::assertion()) + } + Err(e) => Err(e), + } + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.verifier.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.verifier.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.verifier.supported_verify_schemes() + } +} diff --git a/vendor/mysql-28.0.0/src/lib.rs b/vendor/mysql-28.0.0/src/lib.rs new file mode 100644 index 0000000000..9683698d48 --- /dev/null +++ b/vendor/mysql-28.0.0/src/lib.rs @@ -0,0 +1,1084 @@ +// Copyright (c) 2020 rust-mysql-simple contributors +// +// Licensed under the Apache License, Version 2.0 +// or the MIT +// license , at your +// option. All files in the project carrying such notice may not be copied, +// modified, or distributed except according to those terms. + +//! This crate offers: +//! +//! * MySql database driver in pure rust; +//! * connection pool. +//! +//! Features: +//! +//! * macOS, Windows and Linux support; +//! * TLS support via **native-tls** or **rustls** (see the [SSL Support](#ssl-support) section); +//! * MySql text protocol support, i.e. support of simple text queries and text result sets; +//! * MySql binary protocol support, i.e. support of prepared statements and binary result sets; +//! * support of multi-result sets; +//! * support of named parameters for prepared statements (see the [Named Parameters](#named-parameters) section); +//! * per-connection cache of prepared statements (see the [Statement Cache](#statement-cache) section); +//! * buffer pool (see the [Buffer Pool](#buffer-pool) section); +//! * support of MySql packets larger than 2^24; +//! * support of Unix sockets and Windows named pipes; +//! * support of custom LOCAL INFILE handlers; +//! * support of MySql protocol compression; +//! * support of auth plugins: +//! * **mysql_native_password** - for MySql prior to v8; +//! * **caching_sha2_password** - for MySql v8 and higher; +//! * **mysql_clear_password** - opt-in (see [`Opts::get_enable_cleartext_plugin`]. +//! +//! ## Installation +//! +//! Put the desired version of the crate into the `dependencies` section of your `Cargo.toml`: +//! +//! ```toml +//! [dependencies] +//! mysql = "*" +//! ``` +//! +//! ## Example +//! +//! ```rust +//! use mysql::*; +//! use mysql::prelude::*; +//! +//! #[derive(Debug, PartialEq, Eq)] +//! struct Payment { +//! customer_id: i32, +//! amount: i32, +//! account_name: Option, +//! } +//! +//! # def_get_opts!(); +//! +//! fn main() -> std::result::Result<(), Box> { +//! let url = "mysql://root:password@localhost:3307/db_name"; +//! # Opts::try_from(url)?; +//! # let url = get_opts(); +//! let pool = Pool::new(url)?; +//! +//! let mut conn = pool.get_conn()?; +//! +//! // Let's create a table for payments. +//! conn.query_drop( +//! r"CREATE TEMPORARY TABLE payment ( +//! customer_id int not null, +//! amount int not null, +//! account_name text +//! )")?; +//! +//! let payments = vec![ +//! Payment { customer_id: 1, amount: 2, account_name: None }, +//! Payment { customer_id: 3, amount: 4, account_name: Some("foo".into()) }, +//! Payment { customer_id: 5, amount: 6, account_name: None }, +//! Payment { customer_id: 7, amount: 8, account_name: None }, +//! Payment { customer_id: 9, amount: 10, account_name: Some("bar".into()) }, +//! ]; +//! +//! // Now let's insert payments to the database +//! conn.exec_batch( +//! r"INSERT INTO payment (customer_id, amount, account_name) +//! VALUES (:customer_id, :amount, :account_name)", +//! payments.iter().map(|p| params! { +//! "customer_id" => p.customer_id, +//! "amount" => p.amount, +//! "account_name" => &p.account_name, +//! }) +//! )?; +//! +//! // Let's select payments from database. Type inference should do the trick here. +//! let selected_payments = conn +//! .query_map( +//! "SELECT customer_id, amount, account_name from payment", +//! |(customer_id, amount, account_name)| { +//! Payment { customer_id, amount, account_name } +//! }, +//! )?; +//! +//! // Let's make sure, that `payments` equals to `selected_payments`. +//! // Mysql gives no guaranties on order of returned rows +//! // without `ORDER BY`, so assume we are lucky. +//! assert_eq!(payments, selected_payments); +//! println!("Yay!"); +//! +//! Ok(()) +//! } +//! ``` +//! +//! ## Crate Features +//! +//! * feature sets: +//! +//! * **default** – includes `buffer-pool` `flate2/zlib` and `derive` +//! * **default-rust** - same as `default` but with `flate2/rust_backend` instead of `flate2/zlib` +//! * **minimal** - includes `flate2/zlib` only +//! * **minimal-rust** - includes `flate2/rust_backend` only +//! +//! * features: +//! * **buffer-pool** – enables buffer pooling +//! (see the [Buffer Pool](#buffer-pool) section) +//! * **derive** – reexports derive macros under `prelude` +//! (see [corresponding section][derive_docs] in the `mysql_common` documentation) +//! +//! * TLS/SSL related features: +//! +//! * **native-tls** – specifies `native-tls` as the TLS backend +//! (see the [SSL Support](#ssl-support) section) +//! * **rustls-tls** – specifies `rustls` as the TLS backend using `aws-lc-rs` crypto provider +//! (see the [SSL Support](#ssl-support) section) +//! * **rustls-tls-ring** – specifies `rustls` as the TLS backend using `ring` crypto provider +//! (see the [SSL Support](#ssl-support) section) +//! * **rustls** - specifies `rustls` as the TLS backend without crypto provider +//! (see the [SSL Support](#ssl-support) section) +//! +//! * features proxied from `mysql_common`: +//! +//! * **derive** - see [this table][common_features]. +//! * **chrono** - see [this table][common_features]. +//! * **time** - see [this table][common_features]. +//! * **bigdecimal** - see [this table][common_features]. +//! * **rust_decimal** - see [this table][common_features]. +//! * **frunk** - see [this table][common_features]. +//! * **binlog** - see [this table][common_features]. +//! +//! Please note, that you'll need to reenable required features if you are using `default-features = false`: +//! +//! ```toml +//! [dependencies] +//! # Lets say that we want to use only the `rustls-tls` feature: +//! mysql = { version = "*", default-features = false, features = ["minimal-rust", "rustls-tls"] } +//! ``` +//! +//! ## API Documentation +//! +//! Please refer to the [crate docs]. +//! +//! ## Basic structures +//! +//! ### `Opts` +//! +//! This structure holds server host name, client username/password and other settings, +//! that controls client behavior. +//! +//! #### URL-based connection string +//! +//! Note, that you can use URL-based connection string as a source of an `Opts` instance. +//! URL schema must be `mysql`. Host, port and credentials, as well as query parameters, +//! should be given in accordance with the RFC 3986. +//! +//! Examples: +//! +//! ```rust +//! # mysql::doctest_wrapper!(__result, { +//! # use mysql::Opts; +//! let _ = Opts::from_url("mysql://localhost/some_db")?; +//! let _ = Opts::from_url("mysql://[::1]/some_db")?; +//! let _ = Opts::from_url("mysql://user:pass%20word@127.0.0.1:3307/some_db?")?; +//! # }); +//! ``` +//! +//! Supported URL parameters (for the meaning of each field please refer to the docs on `Opts` +//! structure in the create API docs): +//! +//! * `user: string` – MySql client user name +//! * `password: string` – MySql client password; +//! * `db_name: string` – MySql database name; +//! * `host: Host` – MySql server hostname/ip; +//! * `port: u16` – MySql server port; +//! * `pool_min: usize` – see [`PoolConstraints::min`]; +//! * `pool_max: usize` – see [`PoolConstraints::max`]; +//! * `prefer_socket: true | false` - see [`Opts::get_prefer_socket`]; +//! * `tcp_keepalive_time_ms: u32` - defines the value (in milliseconds) +//! of the `tcp_keepalive_time` field in the `Opts` structure; +//! * `tcp_keepalive_probe_interval_secs: u32` - defines the value +//! of the `tcp_keepalive_probe_interval_secs` field in the `Opts` structure; +//! * `tcp_keepalive_probe_count: u32` - defines the value +//! of the `tcp_keepalive_probe_count` field in the `Opts` structure; +//! * `tcp_connect_timeout_ms: u64` - defines the value (in milliseconds) +//! of the `tcp_connect_timeout` field in the `Opts` structure; +//! * `tcp_user_timeout_ms` - defines the value (in milliseconds) +//! of the `tcp_user_timeout` field in the `Opts` structure; +//! * `stmt_cache_size: u32` - defines the value of the same field in the `Opts` structure; +//! * `enable_cleartext_plugin` – see [`Opts::get_enable_cleartext_plugin`]; +//! * `secure_auth` – see [`Opts::get_secure_auth`]; +//! * `reset_connection` – see [`PoolOpts::reset_connection`]; +//! * `check_health` – see [`PoolOpts::check_health`]; +//! * `compress` - defines the value of the same field in the `Opts` structure. +//! Supported value are: +//! * `true` - enables compression with the default compression level; +//! * `fast` - enables compression with "fast" compression level; +//! * `best` - enables compression with "best" compression level; +//! * `1`..`9` - enables compression with the given compression level. +//! * `socket` - socket path on UNIX, or pipe name on Windows. +//! +//! ### `OptsBuilder` +//! +//! It's a convenient builder for the `Opts` structure. It defines setters for fields +//! of the `Opts` structure. +//! +//! ```no_run +//! # mysql::doctest_wrapper!(__result, { +//! # use mysql::*; +//! let opts = OptsBuilder::new() +//! .user(Some("foo")) +//! .db_name(Some("bar")); +//! let _ = Conn::new(opts)?; +//! # }); +//! ``` +//! +//! ### `Conn` +//! +//! This structure represents an active MySql connection. It also holds statement cache +//! and metadata for the last result set. +//! +//! Conn's destructor will gracefully disconnect it from the server. +//! +//! ### `Transaction` +//! +//! It's a simple wrapper on top of a routine, that starts with `START TRANSACTION` +//! and ends with `COMMIT` or `ROLLBACK`. +//! +//! ``` +//! # mysql::doctest_wrapper!(__result, { +//! use mysql::*; +//! use mysql::prelude::*; +//! +//! let pool = Pool::new(get_opts())?; +//! let mut conn = pool.get_conn()?; +//! +//! let mut tx = conn.start_transaction(TxOpts::default())?; +//! tx.query_drop("CREATE TEMPORARY TABLE tmp (TEXT a)")?; +//! tx.exec_drop("INSERT INTO tmp (a) VALUES (?)", ("foo",))?; +//! let val: Option = tx.query_first("SELECT a from tmp")?; +//! assert_eq!(val.unwrap(), "foo"); +//! // Note, that transaction will be rolled back implicitly on Drop, if not committed. +//! tx.rollback(); +//! +//! let val: Option = conn.query_first("SELECT a from tmp")?; +//! assert_eq!(val, None); +//! # }); +//! ``` +//! +//! ### `Pool` +//! +//! It's a reference to a connection pool, that can be cloned and shared between threads. +//! +//! ``` +//! # mysql::doctest_wrapper!(__result, { +//! use mysql::*; +//! use mysql::prelude::*; +//! +//! use std::thread::spawn; +//! +//! let pool = Pool::new(get_opts())?; +//! +//! let handles = (0..4).map(|i| { +//! spawn({ +//! let pool = pool.clone(); +//! move || { +//! let mut conn = pool.get_conn()?; +//! conn.exec_first::("SELECT ? * 10", (i,)) +//! .map(Option::unwrap) +//! } +//! }) +//! }); +//! +//! let result: Result> = handles.map(|handle| handle.join().unwrap()).collect(); +//! +//! assert_eq!(result.unwrap(), vec![0, 10, 20, 30]); +//! # }); +//! ``` +//! +//! ### `Statement` +//! +//! Statement, actually, is just an identifier coupled with statement metadata, i.e an information +//! about its parameters and columns. Internally the `Statement` structure also holds additional +//! data required to support named parameters (see bellow). +//! +//! ``` +//! # mysql::doctest_wrapper!(__result, { +//! use mysql::*; +//! use mysql::prelude::*; +//! +//! let pool = Pool::new(get_opts())?; +//! let mut conn = pool.get_conn()?; +//! +//! let stmt = conn.prep("DO ?")?; +//! +//! // The prepared statement will return no columns. +//! assert!(stmt.columns().is_empty()); +//! +//! // The prepared statement have one parameter. +//! let param = stmt.params().get(0).unwrap(); +//! assert_eq!(param.schema_str(), ""); +//! assert_eq!(param.table_str(), ""); +//! assert_eq!(param.name_str(), "?"); +//! # }); +//! ``` +//! +//! ### `Value` +//! +//! This enumeration represents the raw value of a MySql cell. Library offers conversion between +//! `Value` and different rust types via `FromValue` trait described below. +//! +//! #### `FromValue` trait +//! +//! This trait is reexported from **mysql_common** create. Please refer to its +//! [crate docs][mysql_common docs] for the list of supported conversions. +//! +//! Trait offers conversion in two flavours: +//! +//! * `from_value(Value) -> T` - convenient, but panicking conversion. +//! +//! Note, that for any variant of `Value` there exist a type, that fully covers its domain, +//! i.e. for any variant of `Value` there exist `T: FromValue` such that `from_value` will never +//! panic. This means, that if your database schema is known, then it's possible to write your +//! application using only `from_value` with no fear of runtime panic. +//! +//! * `from_value_opt(Value) -> Option` - non-panicking, but less convenient conversion. +//! +//! This function is useful to probe conversion in cases, where source database schema +//! is unknown. +//! +//! ``` +//! # mysql::doctest_wrapper!(__result, { +//! use mysql::*; +//! use mysql::prelude::*; +//! +//! let via_test_protocol: u32 = from_value(Value::Bytes(b"65536".to_vec())); +//! let via_bin_protocol: u32 = from_value(Value::UInt(65536)); +//! assert_eq!(via_test_protocol, via_bin_protocol); +//! +//! let unknown_val = // ... +//! # Value::Time(false, 10, 2, 30, 0, 0); +//! +//! // Maybe it is a float? +//! let unknown_val = match from_value_opt::(unknown_val) { +//! Ok(float) => { +//! println!("A float value: {}", float); +//! return Ok(()); +//! } +//! Err(FromValueError(unknown_val)) => unknown_val, +//! }; +//! +//! // Or a string? +//! let unknown_val = match from_value_opt::(unknown_val) { +//! Ok(string) => { +//! println!("A string value: {}", string); +//! return Ok(()); +//! } +//! Err(FromValueError(unknown_val)) => unknown_val, +//! }; +//! +//! // Screw this, I'll simply match on it +//! match unknown_val { +//! val @ Value::NULL => { +//! println!("An empty value: {:?}", from_value::>(val)) +//! }, +//! val @ Value::Bytes(..) => { +//! // It's non-utf8 bytes, since we already tried to convert it to String +//! println!("Bytes: {:?}", from_value::>(val)) +//! } +//! val @ Value::Int(..) => { +//! println!("A signed integer: {}", from_value::(val)) +//! } +//! val @ Value::UInt(..) => { +//! println!("An unsigned integer: {}", from_value::(val)) +//! } +//! Value::Float(..) => unreachable!("already tried"), +//! val @ Value::Double(..) => { +//! println!("A double precision float value: {}", from_value::(val)) +//! } +//! val @ Value::Date(..) => { +//! use time::PrimitiveDateTime; +//! println!("A date value: {}", from_value::(val)) +//! } +//! val @ Value::Time(..) => { +//! use std::time::Duration; +//! println!("A time value: {:?}", from_value::(val)) +//! } +//! } +//! # }); +//! ``` +//! +//! ### `Row` +//! +//! Internally `Row` is a vector of `Value`s, that also allows indexing by a column name/offset, +//! and stores row metadata. Library offers conversion between `Row` and sequences of Rust types +//! via `FromRow` trait described below. +//! +//! #### `FromRow` trait +//! +//! This trait is reexported from **mysql_common** create. Please refer to its +//! [crate docs][mysql_common docs] for the list of supported conversions. +//! +//! This conversion is based on the `FromValue` and so comes in two similar flavours: +//! +//! * `from_row(Row) -> T` - same as `from_value`, but for rows; +//! * `from_row_opt(Row) -> Option` - same as `from_value_opt`, but for rows. +//! +//! [`Queryable`](#queryable) +//! trait offers implicit conversion for rows of a query result, +//! that is based on this trait. +//! +//! ``` +//! # mysql::doctest_wrapper!(__result, { +//! use mysql::*; +//! use mysql::prelude::*; +//! +//! let mut conn = Conn::new(get_opts())?; +//! +//! // Single-column row can be converted to a singular value: +//! let val: Option = conn.query_first("SELECT 'foo'")?; +//! assert_eq!(val.unwrap(), "foo"); +//! +//! // Example of a multi-column row conversion to an inferred type: +//! let row = conn.query_first("SELECT 255, 256")?; +//! assert_eq!(row, Some((255u8, 256u16))); +//! +//! // The FromRow trait does not support to-tuple conversion for rows with more than 12 columns, +//! // but you can do this by hand using row indexing or `Row::take` method: +//! let row: Row = conn.exec_first("select 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12", ())?.unwrap(); +//! for i in 0..row.len() { +//! assert_eq!(row[i], Value::Int(i as i64)); +//! } +//! +//! // Another way to handle wide rows is to use HList (requires `mysql_common/frunk` feature) +//! use frunk::{HList, hlist, hlist_pat}; +//! let query = "select 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15"; +//! type RowType = HList!(u8, u16, u32, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8, u8); +//! let first_three_columns = conn.query_map(query, |row: RowType| { +//! // do something with the row (see the `frunk` crate documentation) +//! let hlist_pat![c1, c2, c3, ...] = row; +//! (c1, c2, c3) +//! }); +//! assert_eq!(first_three_columns.unwrap(), vec![(0_u8, 1_u16, 2_u32)]); +//! +//! // Some unknown row +//! let row: Row = conn.query_first( +//! // ... +//! # "SELECT 255, Null", +//! )?.unwrap(); +//! +//! for column in row.columns_ref() { +//! // Cells in a row can be indexed by numeric index or by column name +//! let column_value = &row[column.name_str().as_ref()]; +//! +//! println!( +//! "Column {} of type {:?} with value {:?}", +//! column.name_str(), +//! column.column_type(), +//! column_value, +//! ); +//! } +//! # }); +//! ``` +//! +//! ### `Params` +//! +//! Represents parameters of a prepared statement, but this type won't appear directly in your code +//! because binary protocol API will ask for `T: Into`, where `Into` is implemented: +//! +//! * for tuples of `Into` types up to arity 12; +//! +//! **Note:** singular tuple requires extra comma, e.g. `("foo",)`; +//! +//! * for `IntoIterator>` for cases, when your statement takes more +//! than 12 parameters; +//! * for named parameters representation (the value of the `params!` macro, described below). +//! +//! ``` +//! # mysql::doctest_wrapper!(__result, { +//! use mysql::*; +//! use mysql::prelude::*; +//! +//! let mut conn = Conn::new(get_opts())?; +//! +//! // Singular tuple requires extra comma: +//! let row: Option = conn.exec_first("SELECT ?", (0,))?; +//! assert_eq!(row.unwrap(), 0); +//! +//! // More than 12 parameters: +//! let row: Option = conn.exec_first( +//! "SELECT CONVERT(? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ? + ?, UNSIGNED)", +//! (0..16).collect::>(), +//! )?; +//! assert_eq!(row.unwrap(), 120); +//! # }); +//! ``` +//! +//! **Note:** Please refer to the [**mysql_common** crate docs][mysql_common docs] for the list +//! of types, that implements `Into`. +//! +//! #### `Serialized`, `Deserialized` +//! +//! Wrapper structures for cases, when you need to provide a value for a JSON cell, +//! or when you need to parse JSON cell as a struct. +//! +//! ```rust +//! # mysql::doctest_wrapper!(__result, { +//! use mysql::*; +//! use mysql::prelude::*; +//! use serde::{Deserialize, Serialize}; +//! +//! /// Serializable structure. +//! #[derive(Debug, PartialEq, Serialize, Deserialize)] +//! struct Example { +//! foo: u32, +//! } +//! +//! // Value::from for Serialized will emit json string. +//! let value = Value::from(Serialized(Example { foo: 42 })); +//! assert_eq!(value, Value::Bytes(br#"{"foo":42}"#.to_vec())); +//! +//! // from_value for Deserialized will parse json string. +//! let structure: Deserialized = from_value(value); +//! assert_eq!(structure, Deserialized(Example { foo: 42 })); +//! # }); +//! ``` +//! +//! ### [`QueryResult`] +//! +//! It's an iterator over rows of a query result with support of multi-result sets. It's intended +//! for cases when you need full control during result set iteration. For other cases +//! [`Queryable`](#queryable) provides a set of methods that will immediately consume +//! the first result set and drop everything else. +//! +//! This iterator is lazy so it won't read the result from server until you iterate over it. +//! MySql protocol is strictly sequential, so `Conn` will be mutably borrowed until the result +//! is fully consumed (please also look at [`QueryResult::iter`] docs). +//! +//! ```rust +//! # mysql::doctest_wrapper!(__result, { +//! use mysql::*; +//! use mysql::prelude::*; +//! +//! let mut conn = Conn::new(get_opts())?; +//! +//! // This query will emit two result sets. +//! let mut result = conn.query_iter("SELECT 1, 2; SELECT 3, 3.14;")?; +//! +//! let mut sets = 0; +//! while let Some(result_set) = result.iter() { +//! sets += 1; +//! +//! println!("Result set columns: {:?}", result_set.columns()); +//! println!( +//! "Result set meta: {}, {:?}, {} {}", +//! result_set.affected_rows(), +//! result_set.last_insert_id(), +//! result_set.warnings(), +//! result_set.info_str(), +//! ); +//! +//! for row in result_set { +//! match sets { +//! 1 => { +//! // First result set will contain two numbers. +//! assert_eq!((1_u8, 2_u8), from_row(row?)); +//! } +//! 2 => { +//! // Second result set will contain a number and a float. +//! assert_eq!((3_u8, 3.14), from_row(row?)); +//! } +//! _ => unreachable!(), +//! } +//! } +//! } +//! +//! assert_eq!(sets, 2); +//! # }); +//! ``` +//! +//! ## Text protocol +//! +//! MySql text protocol is implemented in the set of `Queryable::query*` methods. It's useful when your +//! query doesn't have parameters. +//! +//! **Note:** All values of a text protocol result set will be encoded as strings by the server, +//! so `from_value` conversion may lead to additional parsing costs. +//! +//! Examples: +//! +//! ```rust +//! # mysql::doctest_wrapper!(__result, { +//! # use mysql::*; +//! # use mysql::prelude::*; +//! let pool = Pool::new(get_opts())?; +//! let val = pool.get_conn()?.query_first("SELECT POW(2, 16)")?; +//! +//! // Text protocol returns bytes even though the result of POW +//! // is actually a floating point number. +//! assert_eq!(val, Some(Value::Bytes("65536".as_bytes().to_vec()))); +//! # }); +//! ``` +//! +//! ### The `TextQuery` trait. +//! +//! The `TextQuery` trait covers the set of `Queryable::query*` methods from the perspective +//! of a query, i.e. `TextQuery` is something, that can be performed if suitable connection +//! is given. Suitable connections are: +//! +//! * `&Pool` +//! * `Conn` +//! * `PooledConn` +//! * `&mut Conn` +//! * `&mut PooledConn` +//! * `&mut Transaction` +//! +//! The unique characteristic of this trait, is that you can give away the connection +//! and thus produce `QueryResult` that satisfies `'static`: +//! +//! ```rust +//! # mysql::doctest_wrapper!(__result, { +//! use mysql::*; +//! use mysql::prelude::*; +//! +//! fn iter(pool: &Pool) -> Result>> { +//! let result = "SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3".run(pool)?; +//! Ok(result.map(|row| row.map(from_row))) +//! } +//! +//! let pool = Pool::new(get_opts())?; +//! +//! let it = iter(&pool)?; +//! +//! assert_eq!(it.collect::>>()?, vec![1, 2, 3]); +//! # }); +//! ``` +//! +//! ## Binary protocol and prepared statements. +//! +//! MySql binary protocol is implemented in `prep`, `close` and the set of `exec*` methods, +//! defined on the [`Queryable`](#queryable) trait. Prepared statements is the only way to +//! pass rust value to the MySql server. MySql uses `?` symbol as a parameter placeholder +//! and it's only possible to use parameters where a single MySql value is expected. +//! For example: +//! +//! ```rust +//! # mysql::doctest_wrapper!(__result, { +//! # use mysql::*; +//! # use mysql::prelude::*; +//! let pool = Pool::new(get_opts())?; +//! let val = pool.get_conn()?.exec_first("SELECT POW(?, ?)", (2, 16))?; +//! +//! assert_eq!(val, Some(Value::Double(65536.0))); +//! # }); +//! ``` +//! +//! ### Statements +//! +//! In MySql each prepared statement belongs to a particular connection and can't be executed +//! on another connection. Trying to do so will lead to an error. The driver won't tie statement +//! to its connection in any way, but one can look on to the connection id, contained +//! in the `Statement` structure. +//! +//! ```rust +//! # mysql::doctest_wrapper!(__result, { +//! # use mysql::*; +//! # use mysql::prelude::*; +//! let pool = Pool::new(get_opts())?; +//! +//! let mut conn_1 = pool.get_conn()?; +//! let mut conn_2 = pool.get_conn()?; +//! +//! let stmt_1 = conn_1.prep("SELECT ?")?; +//! +//! // stmt_1 is for the conn_1, .. +//! assert!(stmt_1.connection_id() == conn_1.connection_id()); +//! assert!(stmt_1.connection_id() != conn_2.connection_id()); +//! +//! // .. so stmt_1 will execute only on conn_1 +//! assert!(conn_1.exec_drop(&stmt_1, ("foo",)).is_ok()); +//! assert!(conn_2.exec_drop(&stmt_1, ("foo",)).is_err()); +//! # }); +//! ``` +//! +//! ### Statement cache +//! +//! #### Note +//! +//! Statement cache only works for: +//! 1. for raw [`Conn`] +//! 2. for [`PooledConn`]: +//! * within its lifetime if [`PoolOpts::reset_connection`] is `true` +//! * within the lifetime of a wrapped [`Conn`] if [`PoolOpts::reset_connection`] is `false` +//! +//! #### Description +//! +//! `Conn` will manage the cache of prepared statements on the client side, so subsequent calls +//! to prepare with the same statement won't lead to a client-server roundtrip. Cache size +//! for each connection is determined by the `stmt_cache_size` field of the `Opts` structure. +//! Statements, that are out of this boundary will be closed in LRU order. +//! +//! Statement cache is completely disabled if `stmt_cache_size` is zero. +//! +//! **Caveats:** +//! +//! * disabled statement cache means, that you have to close statements yourself using +//! `Conn::close`, or they'll exhaust server limits/resources; +//! +//! * you should be aware of the [`max_prepared_stmt_count`][max_prepared_stmt_count] +//! option of the MySql server. If the number of active connections times the value +//! of `stmt_cache_size` is greater, than you could receive an error while preparing +//! another statement. +//! +//! ### Named parameters +//! +//! MySql itself doesn't have named parameters support, so it's implemented on the client side. +//! One should use `:name` as a placeholder syntax for a named parameter. Named parameters uses +//! the following naming convention: +//! +//! * parameter name must start with either `_` or `a..z` +//! * parameter name may continue with `_`, `a..z` and `0..9` +//! +//! Named parameters may be repeated within the statement, e.g `SELECT :foo, :foo` will require +//! a single named parameter `foo` that will be repeated on the corresponding positions during +//! statement execution. +//! +//! One should use the `params!` macro to build parameters for execution. +//! +//! **Note:** Positional and named parameters can't be mixed within the single statement. +//! +//! Examples: +//! +//! ```rust +//! # mysql::doctest_wrapper!(__result, { +//! # use mysql::*; +//! # use mysql::prelude::*; +//! let pool = Pool::new(get_opts())?; +//! +//! let mut conn = pool.get_conn()?; +//! let stmt = conn.prep("SELECT :foo, :bar, :foo")?; +//! +//! let foo = 42; +//! +//! let val_13 = conn.exec_first(&stmt, params! { "foo" => 13, "bar" => foo })?.unwrap(); +//! // Short syntax is available when param name is the same as variable name: +//! let val_42 = conn.exec_first(&stmt, params! { foo, "bar" => 13 })?.unwrap(); +//! +//! assert_eq!((foo, 13, foo), val_42); +//! assert_eq!((13, foo, 13), val_13); +//! # }); +//! ``` +//! +//! ### Buffer pool +//! +//! Crate uses the global lock-free buffer pool for the purpose of IO and data serialization/deserialization, +//! that helps to avoid allocations for basic scenarios. You can control its characteristics using +//! the following environment variables: +//! +//! * `RUST_MYSQL_BUFFER_POOL_CAP` (defaults to 128) – controls the pool capacity. Dropped buffer will +//! be immediately deallocated if the pool is full. Set it to `0` to disable the pool at runtime. +//! +//! * `RUST_MYSQL_BUFFER_SIZE_CAP` (defaults to 4MiB) – controls the maximum capacity of a buffer +//! stored in the pool. Capacity of a dropped buffer will be shrunk to this value when buffer +//! is returned to the pool. +//! +//! To completely disable the pool (say you are using jemalloc) please remove the `buffer-pool` feature +//! from the set of default crate features (see the [Crate Features](#crate-features) section). +//! +//! ### `BinQuery` and `BatchQuery` traits. +//! +//! `BinQuery` and `BatchQuery` traits covers the set of `Queryable::exec*` methods from +//! the perspective of a query, i.e. `BinQuery` is something, that can be performed if suitable +//! connection is given (see [`TextQuery`](#the-textquery-trait) section for the list +//! of suitable connections). +//! +//! As with the [`TextQuery`](#the-textquery-trait) you can give away the connection and acquire +//! `QueryResult` that satisfies `'static`. +//! +//! `BinQuery` is for prepared statements, and prepared statements requires a set of parameters, +//! so `BinQuery` is implemented for `QueryWithParams` structure, that can be acquired, using +//! `WithParams` trait. +//! +//! Example: +//! +//! ```rust +//! # mysql::doctest_wrapper!(__result, { +//! use mysql::*; +//! use mysql::prelude::*; +//! +//! let pool = Pool::new(get_opts())?; +//! +//! let result: Option<(u8, u8, u8)> = "SELECT ?, ?, ?" +//! .with((1, 2, 3)) // <- WithParams::with will construct an instance of QueryWithParams +//! .first(&pool)?; // <- QueryWithParams is executed on the given pool +//! +//! assert_eq!(result.unwrap(), (1, 2, 3)); +//! # }); +//! ``` +//! +//! The `BatchQuery` trait is a helper for batch statement execution. It's implemented for +//! `QueryWithParams` where parameters is an iterator over parameters: +//! +//! ```rust +//! # mysql::doctest_wrapper!(__result, { +//! use mysql::*; +//! use mysql::prelude::*; +//! +//! let pool = Pool::new(get_opts())?; +//! let mut conn = pool.get_conn()?; +//! +//! "CREATE TEMPORARY TABLE batch (x INT)".run(&mut conn)?; +//! "INSERT INTO batch (x) VALUES (?)" +//! .with((0..3).map(|x| (x,))) // <- QueryWithParams constructed with an iterator +//! .batch(&mut conn)?; // <- batch execution is preformed here +//! +//! let result: Vec = "SELECT x FROM batch".fetch(conn)?; +//! +//! assert_eq!(result, vec![0, 1, 2]); +//! # }); +//! ``` +//! +//! ### `Queryable` +//! +//! The `Queryable` trait defines common methods for `Conn`, `PooledConn` and `Transaction`. +//! The set of basic methods consts of: +//! +//! * `query_iter` - basic methods to execute text query and get `QueryResult`; +//! * `prep` - basic method to prepare a statement; +//! * `exec_iter` - basic method to execute statement and get `QueryResult`; +//! * `close` - basic method to close the statement; +//! +//! The trait also defines the set of helper methods, that is based on basic methods. +//! These methods will consume only the first result set, other result sets will be dropped: +//! +//! * `{query|exec}` - to collect the result into a `Vec`; +//! * `{query|exec}_first` - to get the first `T: FromRow`, if any; +//! * `{query|exec}_map` - to map each `T: FromRow` to some `U`; +//! * `{query|exec}_fold` - to fold the set of `T: FromRow` to a single value; +//! * `{query|exec}_drop` - to immediately drop the result. +//! +//! The trait also defines the `exec_batch` function, which is a helper for batch statement +//! execution. +//! +//! ## SSL Support +//! +//! SSL support comes in two flavors: +//! +//! 1. Based on the `native-tls` crate – native TLS backend. +//! +//! This uses the native OS SSL/TLS provider. Enabled by the **rustls-tls** feature. +//! +//! 2. Based on the `rustls` – TLS backend written in Rust. You have three options here: +//! +//! 1. **rustls-tls** feature enables `rustls` backend with `aws-lc-rs` crypto provider +//! 2. **rustls-tls-ring** feature enables `rustls` backend with `ring` crypto provider +//! 3. **rustls** feature enables `rustls` backend without crypto provider — you have to +//! install your own provider to avoid "no process-level CryptoProvider available" error +//! (see relevant section of the [`rustls` crate docs](https://docs.rs/rustls)) +//! +//! Please also note a few things about **rustls**: +//! +//! * it will fail if you'll try to connect to the server by its IP address, hostname is required; +//! * it, most likely, won't work on windows, at least with default server certs, generated by the +//! MySql installer. +//! +//! [crate docs]: https://docs.rs/mysql +//! [mysql_common docs]: https://docs.rs/mysql_common +//! [max_prepared_stmt_count]: https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_max_prepared_stmt_count +//! [derive_docs]: https://docs.rs/mysql_common/latest/mysql_common/#derive-macros +//! [common_features]: https://docs.rs/mysql_common/latest/mysql_common/#crate-features + +#![cfg_attr(feature = "nightly", feature(test))] +#![cfg_attr(docsrs, feature(doc_cfg))] +#[cfg(feature = "nightly")] +extern crate test; + +use mysql_common as myc; + +mod buffer_pool; +mod conn; +pub mod error; +mod io; + +#[cfg(feature = "derive")] +pub extern crate mysql_common; + +#[doc(inline)] +pub use crate::myc::constants as consts; + +#[doc(inline)] +pub use crate::myc::packets::{binlog_request::BinlogRequest, BinlogDumpFlags}; + +#[cfg(feature = "binlog")] +#[cfg_attr(docsrs, doc(cfg(feature = "binlog")))] +pub mod binlog { + #[doc(inline)] + pub use crate::myc::binlog::consts::*; + + #[doc(inline)] + pub use crate::myc::binlog::{events, jsonb, jsondiff, row, value}; +} + +#[cfg(any(feature = "native-tls", feature = "rustls"))] +#[cfg_attr( + docsrs, + doc(cfg(any( + feature = "native-tls", + feature = "rustls-tls", + feature = "rustls-tls-ring" + ))) +)] +#[doc(inline)] +pub use crate::conn::opts::ClientIdentity; + +#[doc(inline)] +pub use crate::myc::packets::{session_state_change, SessionStateInfo}; + +#[cfg(feature = "binlog")] +#[doc(inline)] +pub use crate::conn::binlog_stream::BinlogStream; +#[doc(inline)] +pub use crate::conn::local_infile::{LocalInfile, LocalInfileHandler}; +#[doc(inline)] +pub use crate::conn::opts::SslOpts; +#[doc(inline)] +pub use crate::conn::opts::{ + pool_opts::{PoolConstraints, PoolOpts}, + ChangeUserOpts, Opts, OptsBuilder, DEFAULT_STMT_CACHE_SIZE, +}; +#[doc(inline)] +pub use crate::conn::pool::{Pool, PooledConn}; +#[doc(inline)] +pub use crate::conn::query::QueryWithParams; +#[doc(inline)] +pub use crate::conn::query_result::{Binary, QueryResult, ResultSet, SetColumns, Text}; +#[doc(inline)] +pub use crate::conn::stmt::Statement; +#[doc(inline)] +pub use crate::conn::transaction::{AccessMode, IsolationLevel, Transaction, TxOpts}; +#[doc(inline)] +pub use crate::conn::Conn; +#[doc(inline)] +pub use crate::error::{DriverError, Error, MySqlError, Result, ServerError, UrlError}; +#[doc(inline)] +pub use crate::myc::packets::Column; +#[doc(inline)] +pub use crate::myc::params::Params; +#[doc(inline)] +pub use crate::myc::proto::codec::Compression; +#[doc(inline)] +pub use crate::myc::row::convert::{from_row, from_row_opt, FromRowError}; +#[doc(inline)] +pub use crate::myc::row::Row; +#[doc(inline)] +pub use crate::myc::value::convert::{from_value, from_value_opt, FromValueError}; +#[doc(inline)] +pub use crate::myc::value::json::{Deserialized, Serialized}; +#[doc(inline)] +pub use crate::myc::value::Value; + +pub mod prelude { + #[doc(inline)] + pub use crate::conn::query::{BatchQuery, BinQuery, TextQuery, WithParams}; + #[doc(inline)] + pub use crate::conn::queryable::{AsStatement, Queryable}; + #[doc(inline)] + pub use crate::myc::prelude::FromRow; + #[doc(inline)] + pub use crate::myc::prelude::{FromValue, ToValue}; + #[doc(inline)] + pub use crate::myc::row::ColumnIndex; + + /// Trait for protocol markers [`crate::Binary`] and [`crate::Text`]. + pub trait Protocol: crate::conn::query_result::Protocol {} + impl Protocol for crate::Binary {} + impl Protocol for crate::Text {} +} + +#[doc(inline)] +pub use crate::myc::params; + +#[doc(hidden)] +#[macro_export] +macro_rules! def_database_url { + () => { + if let Ok(url) = std::env::var("DATABASE_URL") { + let opts = $crate::Opts::from_url(&url).expect("DATABASE_URL invalid"); + if opts + .get_db_name() + .expect("a database name is required") + .is_empty() + { + panic!("database name is empty"); + } + url + } else { + "mysql://root:password@localhost:3307/mysql".into() + } + }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! def_get_opts { + () => { + pub fn test_ssl() -> bool { + let ssl = std::env::var("SSL").ok().unwrap_or("false".into()); + ssl == "true" || ssl == "1" + } + + pub fn test_compression() -> bool { + let compress = std::env::var("COMPRESS").ok().unwrap_or("false".into()); + compress == "true" || compress == "1" + } + + pub fn get_opts() -> $crate::OptsBuilder { + let database_url = $crate::def_database_url!(); + let mut builder = + $crate::OptsBuilder::from_opts($crate::Opts::from_url(&*database_url).unwrap()) + .init(vec!["SET GLOBAL sql_mode = 'TRADITIONAL'"]) + .connect_attrs::(None); + if test_compression() { + builder = builder.compress(Some(Default::default())); + } + if test_ssl() { + let ssl_opts = $crate::SslOpts::default() + .with_danger_skip_domain_validation(true) + .with_danger_accept_invalid_certs(true); + builder = builder.prefer_socket(false).ssl_opts(ssl_opts); + } + builder + } + }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! doctest_wrapper { + ($body:block) => { + fn fun() { + $crate::def_get_opts!(); + $body; + } + fun() + }; + (__result, $body:block) => { + fn fun() -> std::result::Result<(), Box> { + $crate::def_get_opts!(); + Ok($body) + } + fun() + }; +} + +#[cfg(test)] +mod test_misc { + #[allow(dead_code)] + fn error_should_implement_send_and_sync() { + fn _dummy(_: T) {} + _dummy(crate::error::Error::FromValueError(crate::Value::NULL)); + } + + #[allow(dead_code)] + fn database_url() { + def_database_url!(); + } + + def_get_opts!(); +}