Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,33 @@ permissions:
contents: read

jobs:
changed-shadow-paths:
name: Detect Shadow-Report Paths
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
outputs:
requires_shadow_reports: ${{ steps.filter.outputs.shadow }}
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Detect planner/IR path changes
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
shadow:
- 'src/operations.rs'
- 'src/query.rs'
- 'src/schema.rs'
- 'src/migrate.rs'
- 'src/backend.rs'
- 'src/connection.rs'
- 'src/ferro/ir/**'
- 'crates/ferro-schema-ir/**'
- 'tests/test_shadow_reports.py'
- 'tests/fixtures/shadow_reports/**'

lint-and-format:
name: Lint & Format (Pre-commit / Prek)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -244,6 +271,64 @@ jobs:
run: |
uv run pytest -v -m "backend_matrix or postgres_only" --db-backends=sqlite,postgres

test-shadow-reports-pr:
name: Shadow reports (touched paths)
runs-on: ubuntu-latest
needs: [changed-shadow-paths]
if: github.event_name == 'pull_request' && needs.changed-shadow-paths.outputs.requires_shadow_reports == 'true'
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: ferro
POSTGRES_PASSWORD: ferro
POSTGRES_DB: ferro
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U ferro -d ferro"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
FERRO_SUPABASE_URL: postgresql://ferro:ferro@127.0.0.1:5432/ferro?sslmode=disable
FERRO_SHADOW_RUNTIME: "1"
FERRO_SHADOW_RUNTIME_STRICT: "1"
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'

- name: Install UV
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Set up Rust
uses: dtolnay/rust-toolchain@stable

- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
prefix-key: v1
cache-on-failure: true

- name: Install dependencies
run: |
uv sync --only-group ci-test --no-install-project --python 3.13

- name: Build Rust extension
run: |
uv run maturin develop

- name: Verify stable shadow reports
run: |
uv run pytest -v tests/test_shadow_reports.py::test_shadow_report_fixture_stable --db-backends=sqlite,postgres

check-conventional-commits:
name: Check Conventional Commits
runs-on: ubuntu-latest
Expand Down Expand Up @@ -293,7 +378,7 @@ jobs:

all-checks:
name: All Checks Passed
needs: [lint-and-format, test-python-pr, test-python-main, test-python-backend-matrix, test-rust]
needs: [changed-shadow-paths, lint-and-format, test-python-pr, test-python-main, test-python-backend-matrix, test-shadow-reports-pr, test-rust]
runs-on: ubuntu-latest
if: always()
steps:
Expand All @@ -306,6 +391,7 @@ jobs:
if ! ok "${{ needs.test-python-pr.result }}"; then exit 1; fi
if ! ok "${{ needs.test-python-main.result }}"; then exit 1; fi
if ! ok "${{ needs.test-python-backend-matrix.result }}"; then exit 1; fi
if ! ok "${{ needs.test-shadow-reports-pr.result }}"; then exit 1; fi
if ! ok "${{ needs.test-rust.result }}"; then exit 1; fi

echo "All checks passed!"
1 change: 1 addition & 0 deletions docs/plans/2026-06-19-001-ir-first-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,7 @@ Append updates as concise entries.
- `2026-06-19` - Branching policy set: phase work branches from `feat/ir-first` and merges back into `feat/ir-first` until final promotion to `main`.
- `2026-06-19` - Phase 0 completed and merged via [#75](https://github.com/syn54x/ferro-orm/pull/75).
- `2026-06-19` - Phase 1 implementation landed on working branch: added `ferro-schema-ir`, Python->SchemaIR compiler, model-set fingerprinting, and stable representative snapshot checks.
- `2026-06-19` - Phase 2 scaffolding landed on working branch: internal shadow runtime flag/hook wiring, semantic comparison harness, stable SQLite/Postgres shadow report fixtures, and touched-path CI gate for shadow reports.

## Immediate next actions

Expand Down
8 changes: 7 additions & 1 deletion docs/plans/ir-first-migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ No user-facing runtime behavior changes expected.

### Phase 2

_TBD_
No user-facing runtime behavior changes expected. Shadow planning is internal-only and defaults off.

| Issue | Change | Impact | User action | Notes |
| --- | --- | --- | --- | --- |
| [#81](https://github.com/syn54x/ferro-orm/issues/81) | Internal shadow planner flag and runtime dual-run compare hooks for query/DDL planning | none | none | Internal env-controlled verification path (`FERRO_SHADOW_RUNTIME` / `FERRO_SHADOW_RUNTIME_STRICT`) for CI and maintainers; no public API behavior cutover |
| [#82](https://github.com/syn54x/ferro-orm/issues/82) | Semantic diff harness for query planning semantics and bind semantics | none | none | Test-only helper `_shadow_compare_query_plan_for_test` + backend-matrix strict checks |
| [#83](https://github.com/syn54x/ferro-orm/issues/83) | Stable SQLite/Postgres shadow reports + touched-path CI enforcement | none | none | Golden shadow reports in `tests/fixtures/shadow_reports/` and path-gated CI workflow job |

### Phase 3

Expand Down
16 changes: 16 additions & 0 deletions src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ pub struct EngineHandle {
spec: Option<PoolSpec>,
/// When false, Ferro skips the identity map for this connection (no lookup/register on load).
identity_map_enabled: bool,
/// Enables internal IR shadow-planner comparisons at runtime.
shadow_runtime_enabled: bool,
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -204,6 +206,7 @@ impl EngineHandle {
pool: Arc::new(RwLock::new(pool)),
spec: Some(spec),
identity_map_enabled: true,
shadow_runtime_enabled: false,
})
}

Expand All @@ -217,6 +220,7 @@ impl EngineHandle {
pool: Arc::new(RwLock::new(BackendPool::Sqlite(Arc::new(pool)))),
spec: None,
identity_map_enabled: true,
shadow_runtime_enabled: false,
}
}

Expand All @@ -230,6 +234,7 @@ impl EngineHandle {
pool: Arc::new(RwLock::new(BackendPool::Postgres(Arc::new(pool)))),
spec: None,
identity_map_enabled: true,
shadow_runtime_enabled: false,
}
}

Expand Down Expand Up @@ -321,6 +326,17 @@ impl EngineHandle {
self
}

#[must_use]
pub fn is_shadow_runtime_enabled(&self) -> bool {
self.shadow_runtime_enabled
}

#[must_use]
pub fn with_shadow_runtime_enabled(mut self, enabled: bool) -> Self {
self.shadow_runtime_enabled = enabled;
self
}

pub fn backend(&self) -> BackendKind {
self.backend
}
Expand Down
12 changes: 11 additions & 1 deletion src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ fn normalized_connection_name(name: Option<String>) -> PyResult<(String, bool)>
}
}

fn shadow_runtime_enabled_from_env() -> bool {
std::env::var("FERRO_SHADOW_RUNTIME")
.map(|value| {
let value = value.trim().to_ascii_lowercase();
value == "1" || value == "true" || value == "yes" || value == "on"
})
.unwrap_or(false)
}

async fn connect_engine_handle(
connection_url: &str,
backend: BackendKind,
Expand Down Expand Up @@ -233,7 +242,8 @@ pub fn connect(
redacted_url, e
))
})?
.with_identity_map_enabled(identity_map);
.with_identity_map_enabled(identity_map)
.with_shadow_runtime_enabled(shadow_runtime_enabled_from_env());

let engine_handle = Arc::new(engine_handle);

Expand Down
6 changes: 6 additions & 0 deletions src/ferro/_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ def _render_migration_sql_for_test(
"""
...

def _shadow_compare_query_plan_for_test(
query_json: str, dialect: str, operation: str = "select"
) -> str:
"""Test-only: compare legacy vs QueryIR-roundtrip query planning semantics."""
...

async def fetch_all(
cls: object, tx_id: Optional[str] = None, using: Optional[str] = None
) -> list[Any]: ...
Expand Down
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(operations::raw_execute, m)?)?;
m.add_function(wrap_pyfunction!(operations::raw_fetch_all, m)?)?;
m.add_function(wrap_pyfunction!(operations::raw_fetch_one, m)?)?;
m.add_function(wrap_pyfunction!(
operations::_shadow_compare_query_plan_for_test,
m
)?)?;
m.add_function(wrap_pyfunction!(connection::reset_engine, m)?)?;
m.add_function(wrap_pyfunction!(connection::set_default_connection, m)?)?;
m.add_function(wrap_pyfunction!(clear_registry, m)?)?;
Expand Down
40 changes: 40 additions & 0 deletions src/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,35 @@ pub fn plan_table_migration(
Ok(plan)
}

fn shadow_compare_migration_plan(
table_lower: &str,
schema: &serde_json::Value,
live: &[LiveColumn],
backend: SqlDialect,
opts: MigrateOptions,
) -> Result<(), String> {
let legacy =
plan_table_migration(table_lower, schema, live, backend, opts).map_err(|e| e.to_string())?;
let schema_roundtrip: serde_json::Value =
serde_json::from_str(&serde_json::to_string(schema).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let live_roundtrip = live.to_vec();
let shadow = plan_table_migration(table_lower, &schema_roundtrip, &live_roundtrip, backend, opts)
.map_err(|e| e.to_string())?;
if legacy.statements == shadow.statements
&& legacy.drop_columns == shadow.drop_columns
&& legacy.warnings == shadow.warnings
{
return Ok(());
}
Err(format!(
"shadow migration-plan mismatch for '{}': legacy={} shadow={}",
table_lower,
serde_json::to_string(&legacy.statements).unwrap_or_else(|_| "<legacy>".to_string()),
serde_json::to_string(&shadow.statements).unwrap_or_else(|_| "<shadow>".to_string())
))
}

/// Plan the `ADD COLUMN` (and any follow-up DDL) for a model column missing
/// from the live table.
fn plan_missing_column(
Expand Down Expand Up @@ -592,6 +621,17 @@ pub async fn internal_migrate(engine: Arc<EngineHandle>, opts: MigrateOptions) -
};

let mut plan = plan_table_migration(&table_lower, &schema, &live, backend, opts)?;
if engine.is_shadow_runtime_enabled()
&& let Err(diff) = shadow_compare_migration_plan(&table_lower, &schema, &live, backend, opts)
{
crate::log_debug(format!("⚠️ Ferro shadow runtime mismatch: {diff}"));
if std::env::var("FERRO_SHADOW_RUNTIME_STRICT")
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
.unwrap_or(false)
{
return Err(pyo3::exceptions::PyRuntimeError::new_err(diff));
}
}
if plan.is_empty() {
warnings.append(&mut plan.warnings);
continue;
Expand Down
Loading
Loading