Contributing & Debugging

Debugging PormG Internals from a Consuming App

PormG uses an internal no-op macro, @pormg_debug, as a compile-time debug hook. In the published package it expands to nothing. This section explains how to activate real breakpoints when you need to investigate a bug that occurs inside PormG while running your own application.

Why @pormg_debug is a no-op by default

Julia macros are expanded at parse time, before the code runs. When PormG is loaded as a registered package, its .ji precompiled cache already contains every @pormg_debug call expanded to nothing. Redefining the macro at runtime cannot retroactively change those expansions.

The solution is to switch your app to use PormG from source, which forces Julia to re-parse every file and apply the new macro definition.


Step-by-step: activating breakpoints from your app

Requirements: Revise.jl and Infiltrator.jl must be available in your local Julia environment (they are not required by PormG itself).

# In your global environment (run once):
] add Revise Infiltrator

1. Switch your app to the local PormG source

Open your app's Julia REPL and run:

] dev C:\path\to\PormG-new-features   # Windows
] dev /path/to/PormG-new-features      # Linux/macOS

This rewrites your app's Manifest.toml to point at the PormG source folder instead of the cached package. Julia will now compile PormG from source on every session.

2. Load Revise and Infiltrator before PormG

Order matters — Revise must be tracking PormG before it is loaded:

using Revise       # must come first
using Infiltrator
using PormG        # now loaded from source, tracked by Revise

3. Re-wire @pormg_debug to real breakpoints

PormG.eval(:(macro pormg_debug(ex); :(Infiltrator.@infiltrate($(esc(ex)))); end))

4. Mark the exact line you want to break on

Open the relevant PormG source file (e.g. src/querybuilder/execution_read.jl) and change the call site from:

@pormg_debug false   # condition false → never fires

to:

@pormg_debug true    # condition true → always fires

Save the file. Revise detects the change and re-parses the function with the new macro definition. The next call to that code path will drop you into the Infiltrator REPL.

5. Reproduce the bug in your app

# Example: investigating a query build issue on Result joins
using MyApp   # or load your models directly

q = M.Result.objects
q.filter("driverid__nationality" => "Brazilian", "positionorder" => 1)
q.values("raceid__year", "raceid__name", "driverid__surname")
df = q |> DataFrame   # breakpoint fires inside PormG here

Infiltrator drops you into an interactive REPL at the marked line, where you can inspect local variables, call functions, and step through the logic.

6. Restore your app to the registered PormG

When you are done debugging, switch back to the published version:

] free PormG

The @pormg_debug call sites in PormG source

All debug hook placements in the source use the pattern:

@pormg_debug false     # change to `true` or a real condition to fire

The false condition means the hook is permanently silent by default. When re-wired to Infiltrator (steps above), change false to true or any expression that reflects the specific scenario you are investigating:

@pormg_debug variable_name == :unexpected_value

How to contribute

  1. Fork the repository on GitHub and clone your fork locally.
  2. Create a feature branch: git checkout -b my-feature
  3. Make your changes, add tests under test/integration/ or test/unit/.
  4. Run the unit tests (no database required):
    julia -O0 --project=. -e 'using Pkg; Pkg.test()'
    This is what CI runs, and the -e matters: handing the test script straight to julia --project=. does not work. The LibPQ, Postgres, SQLite and MySQL drivers are weak dependencies, so the package environment never installs them; Pkg.test() resolves them from the test target instead. -O0 is optional but worth it: the suite spends most of its time compiling code that mostly runs once, and skipping LLVM optimization makes the whole run about 40% faster. Pkg.test() passes the flag on to the process that runs the tests.
  5. Run the integration tests (requires a live PostgreSQL, SQLite or MySQL database). Use --project=test/integration — that environment carries the drivers:
    julia -t auto --project=test/integration test/integration/runtests.jl
    That runs PostgreSQL through LibPQ, the default driver. Postgres.jl is in use too, so a change that reaches either driver's extension, or how results are decoded, runs the suite again through it, after the first run, never at the same time (the two reseed the same fixture):
    PORMG_POSTGRES_DRIVER=Postgres julia -t auto --project=test/integration test/integration/runtests.jl
    One file at a time — unit or integration — uses the same environment:
    julia --project=test/integration test/unit/test_order_by_nulls.jl
    First use in a fresh clone needs it instantiated once:
    julia --project=test/integration -e 'using Pkg; Pkg.instantiate()'
    MySQL runs against a local MySQL 8.4 (a Docker mysql:8.4 container is enough) and a test/integration/db_my/connection.yml using adapter: MySQL. The full suite runs there as on the other engines, and MySQL's own files run on their own, after it:
    PORMG_DB=db_my julia -t auto --project=test/integration test/integration/runtests.jl
    PORMG_DB=db_my julia -t auto --project=test/integration test/integration/test_mysql_backend.jl
    PORMG_DB=db_my julia -t auto --project=test/integration test/integration/test_mysql_reads.jl
    PORMG_DB=db_my julia -t auto --project=test/integration test/integration/test_mysql_writes.jl
    PORMG_DB=db_my julia -t auto --project=test/integration test/integration/test_mysql_bulk.jl
    PORMG_DB=db_my julia -t auto --project=test/integration test/integration/test_mysql_migrations.jl
    PORMG_DB=db_my julia -t auto --project=test/integration test/integration/test_mysql_alters.jl
    PORMG_DB=db_my julia -t auto --project=test/integration test/integration/test_mysql_inspectdb.jl
  6. Submit a pull request against main.

Testing conventions

  • Every @testset block must be commented: explain what is tested, the expected SQL, and why the behaviour matters.
  • Use the Formula 1 dataset models (M.Driver, M.Result, M.Race, etc.) for all user-facing examples. Generic User/Post examples are not accepted in this codebase.
  • Use show_query=:sql to capture generated SQL in tests for assertion.
  • An integration test of an optional feature (a row of the backend capability table) asks requires(:feature) from test/integration/common_setup.jl, never which engine it runs on. It returns true when this run's backend has the feature. Otherwise it records the skip with its reason, and the test asserts the BackendCapabilityError instead:
    if requires(:arrays)
        # … the feature, against the live database …
    else
        @test_throws PormG.BackendCapabilityError …
    end
  • Where a test differs by engine for any other reason, it names each engine and never falls through an else, because a backend added later would inherit that else without a decision. An engine's own text (a plan, an error's fields, a placeholder) or how the test drives the engine is written as by_engine(postgres = …, sqlite = …, mysql = …). It raises an error for an engine with no row. A testset that exercises one engine's own machinery (its catalog, its server sessions) is guarded with only_on(:postgres, "why"), which records the skip like requires does. A value is never keyed by engine: it is checked against an oracle on every engine. test/unit/test_engine_branches.jl counts the by-name branches (isa PormGPostgres, PORMG_DB_FOLDER == …) left in test/integration/, so a new one fails until it is reviewed.
  • Avoid leaving @pormg_debug true in committed code — always reset to false.