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
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[workspace]
members = [".", "crates/ferro-schema-ir"]

[package]
name = "ferro"
version = "0.11.0"
Expand Down Expand Up @@ -31,3 +34,4 @@ sea-query = { version = "0.32", features = ["with-uuid"] }
tokio = { version = "1.49", features = ["full"] }
pyo3-async-runtimes = { version = "0.27", features = ["tokio-runtime"] }
uuid = { version = "1.11", features = ["v4"] }
ferro-schema-ir = { path = "crates/ferro-schema-ir" }
8 changes: 8 additions & 0 deletions crates/ferro-schema-ir/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[package]
name = "ferro-schema-ir"
version = "0.1.0"
edition = "2024"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
186 changes: 186 additions & 0 deletions crates/ferro-schema-ir/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IrEnvelope<T> {
pub ir_kind: String,
pub ir_version: u32,
pub payload: T,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SchemaIrPayload {
pub dialect_agnostic: bool,
pub models: Vec<SchemaModel>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SchemaModel {
pub model_name: String,
pub table_name: String,
pub columns: Vec<SchemaColumn>,
pub foreign_keys: Vec<SchemaForeignKey>,
pub indexes: Vec<SchemaIndex>,
pub uniques: Vec<SchemaUnique>,
pub checks: Vec<SchemaCheck>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SchemaColumn {
pub name: String,
pub logical_type: String,
pub db_type: String,
pub nullable: bool,
pub primary_key: bool,
pub autoincrement: bool,
pub unique: bool,
pub index: bool,
pub default: Option<Value>,
pub format: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SchemaForeignKey {
pub column: String,
pub to_table: String,
pub to_column: String,
pub on_delete: Option<String>,
pub name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SchemaIndex {
pub name: String,
pub columns: Vec<String>,
pub unique: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SchemaUnique {
pub name: String,
pub columns: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SchemaCheck {
pub name: String,
pub expression: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QueryIrPayload {
pub model_name: String,
#[serde(rename = "where")]
pub where_clause: Vec<QueryNode>,
pub order_by: Vec<QueryOrderBy>,
pub limit: Option<u64>,
pub offset: Option<u64>,
pub m2m: Option<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QueryOrderBy {
pub column: String,
pub direction: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "node_kind")]
pub enum QueryNode {
#[serde(rename = "leaf")]
Leaf {
operator: String,
column: String,
value: QueryValue,
},
#[serde(rename = "compound")]
Compound {
operator: String,
left: Box<QueryNode>,
right: Box<QueryNode>,
},
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct QueryValue {
pub kind: String,
pub value: Value,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CodecIrPayload {
pub bind_rules: Vec<CodecBindRule>,
pub fetch_rules: Vec<CodecFetchRule>,
pub hydration_abi: HydrationAbi,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CodecBindRule {
pub logical_type: String,
pub db_type: String,
pub non_null_wire_kind: String,
pub null_wire_kind: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CodecFetchRule {
pub db_type: String,
pub wire_kind: String,
pub python_kind: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HydrationAbi {
pub constructor_mode: String,
pub required_slots: Vec<String>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn schema_fixture_roundtrip() {
let fixture = include_str!("../../../tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json");
let parsed: serde_json::Value =
serde_json::from_str(fixture).expect("schema fixture must parse");
let ir = parsed
.get("ir")
.cloned()
.expect("fixture must contain ir envelope");
let envelope: IrEnvelope<SchemaIrPayload> =
serde_json::from_value(ir.clone()).expect("schema IR must deserialize");
let encoded = serde_json::to_value(&envelope).expect("schema IR must serialize");
assert_eq!(encoded, ir, "schema round-trip must not drift");
}

#[test]
fn query_fixture_roundtrip() {
let fixture = include_str!("../../../tests/fixtures/ir_vectors/query_user_compound_v1.json");
let parsed: serde_json::Value =
serde_json::from_str(fixture).expect("query fixture must parse");
let ir = parsed
.get("ir")
.cloned()
.expect("fixture must contain ir envelope");
let envelope: IrEnvelope<QueryIrPayload> =
serde_json::from_value(ir.clone()).expect("query IR must deserialize");
let encoded = serde_json::to_value(&envelope).expect("query IR must serialize");
assert_eq!(encoded, ir, "query round-trip must not drift");
}

#[test]
fn codec_fixture_roundtrip() {
let fixture = include_str!("../../../tests/fixtures/ir_vectors/codec_registry_core_v1.json");
let parsed: serde_json::Value =
serde_json::from_str(fixture).expect("codec fixture must parse");
let ir = parsed
.get("ir")
.cloned()
.expect("fixture must contain ir envelope");
let envelope: IrEnvelope<CodecIrPayload> =
serde_json::from_value(ir.clone()).expect("codec IR must deserialize");
let encoded = serde_json::to_value(&envelope).expect("codec IR must serialize");
assert_eq!(encoded, ir, "codec round-trip must not drift");
}
}
23 changes: 17 additions & 6 deletions docs/plans/2026-06-19-001-ir-first-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Issue references:

### Phase 1 - Build IR core and compiler

Status: `Not started`
Status: `In progress`

Issue references:

Expand All @@ -197,13 +197,23 @@ Issue references:
- Introduce a Rust-owned IR crate and compile Python model metadata into deterministic IR artifacts.

**Deliverables**
- [ ] `ferro-schema-ir` crate added with versioned serde types.
- [ ] Python -> SchemaIR compiler path added.
- [ ] IR hashing/fingerprinting persisted for model sets.
- [x] `ferro-schema-ir` crate added with versioned serde types.
- [x] Python -> SchemaIR compiler path added.
- [x] IR hashing/fingerprinting persisted for model sets.

**Exit gate**
- [ ] Existing representative models compile to stable IR snapshots in CI.
- [ ] No user-visible behavior changes yet.
- [x] Existing representative models compile to stable IR snapshots in CI.
- [x] No user-visible behavior changes yet.

**Evidence (working branch; pending merge to `feat/ir-first`)**
- IR crate: `crates/ferro-schema-ir/` (versioned serde types + RFC vector round-trip tests)
- Compiler + persistence: `src/ferro/ir/compiler.py`, `src/ferro/ir/__init__.py`, `src/ferro/metaclass.py`, `src/ferro/relations/__init__.py`, `src/ferro/state.py`
- Stable representative snapshot fixture: `tests/fixtures/ir_vectors/schema_phase1_fixture_models_v1.json`
- CI gate extension: `tests/test_ir_vectors_contract.py` (snapshot-compare + determinism tests)
- Verification commands:
- `cargo test -p ferro-schema-ir`
- `uv run pytest tests/test_ir_vectors_contract.py -q`
- `uv run pytest tests/test_cross_emitter_parity.py -q`

---

Expand Down Expand Up @@ -473,6 +483,7 @@ Append updates as concise entries.
- `2026-06-19` - Roadmap initialized.
- `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.

## 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 @@ -33,7 +33,13 @@ No user-facing runtime behavior changes expected.

### Phase 1

_TBD_
No user-facing runtime behavior changes expected.

| Issue | Change | Impact | User action | Notes |
| --- | --- | --- | --- | --- |
| [#77](https://github.com/syn54x/ferro-orm/issues/77) | Add `ferro-schema-ir` crate with versioned serde IR contracts | none | none | Internal contract crate only; artifacts: `crates/ferro-schema-ir/`, RFC vector round-trip tests |
| [#78](https://github.com/syn54x/ferro-orm/issues/78) | Add deterministic Python -> SchemaIR compiler path | none | none | Internal compiler path only; artifacts: `src/ferro/ir/compiler.py`, model registration + relationship-resolution hooks |
| [#79](https://github.com/syn54x/ferro-orm/issues/79) | Persist model-set fingerprints and stable representative snapshots | none | none | Infra/test only; artifacts: `tests/fixtures/ir_vectors/schema_phase1_fixture_models_v1.json`, `tests/test_ir_vectors_contract.py` |

### Phase 2

Expand Down
13 changes: 13 additions & 0 deletions src/ferro/ir/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Public SchemaIR compilation API for the Python runtime."""

from .compiler import (
compile_model_schema_ir,
compile_registry_schema_ir,
schema_ir_fingerprint,
)

__all__ = [
"compile_model_schema_ir",
"compile_registry_schema_ir",
"schema_ir_fingerprint",
]
Loading
Loading