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
156 changes: 156 additions & 0 deletions .github/workflows/build_and_release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
name: Build, Test, and Release BridgeORM Wheels

on:
push:
branches: [main]
pull_request:
branches: [main]
release:
types: [published]

env:
PRIMARY_PYTHON_VERSION: "3.11"
RUST_TOOLCHAIN_VERSION: "stable"

jobs:

rust_quality_gates:
name: Rust — Format & Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- name: Verify Rust formatting
run: cargo fmt --all -- --check

- name: Run Clippy lints (deny warnings in CI)
run: cargo clippy --all-targets --all-features -- -D warnings

- name: Run Rust unit tests
run: cargo test --all-features

- name: Run supply chain security audit
run: |
cargo install cargo-deny --locked
cargo deny check

build_binary_wheels:
name: Build Wheel — ${{ matrix.platform.runner }} / ${{ matrix.platform.target }}
needs: rust_quality_gates
runs-on: ${{ matrix.platform.runner }}
strategy:
fail-fast: false
matrix:
platform:
- runner: ubuntu-latest
target: x86_64
manylinux: auto
- runner: ubuntu-latest
target: aarch64
manylinux: auto
- runner: macos-13
target: x86_64
manylinux: ""
- runner: macos-14
target: aarch64
manylinux: ""
- runner: windows-latest
target: x86_64
manylinux: ""

steps:
- uses: actions/checkout@v4

- name: Build wheels via cibuildwheel
uses: pypa/cibuildwheel@v2.19.2
env:
CIBW_BUILD: cp311-* cp312-*
CIBW_ARCHS: ${{ matrix.platform.target }}
CIBW_MANYLINUX_X86_64_IMAGE: ${{ matrix.platform.manylinux }}
CIBW_BUILD_FRONTEND: build[uv]

- uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.platform.runner }}-${{ matrix.platform.target }}
path: ./wheelhouse/*.whl

python_integration_tests:
name: Python Integration Tests — ${{ matrix.database.name }}
needs: build_binary_wheels
runs-on: ubuntu-latest
strategy:
matrix:
database:
- name: postgres
image: postgres:16
port: 5432
url: "postgresql://bridge_test:bridge_test@localhost:5432/bridge_test"
- name: mysql
image: mysql:8.3
port: 3306
url: "mysql://bridge_test:bridge_test@localhost:3306/bridge_test"
- name: sqlite
image: ""
port: 0
url: "sqlite:///tmp/bridge_test.db"

services:
database:
image: ${{ matrix.database.image }}
env:
POSTGRES_USER: bridge_test
POSTGRES_PASSWORD: bridge_test
POSTGRES_DB: bridge_test
MYSQL_ROOT_PASSWORD: bridge_test
MYSQL_USER: bridge_test
MYSQL_PASSWORD: bridge_test
MYSQL_DATABASE: bridge_test
ports:
- ${{ matrix.database.port }}:${{ matrix.database.port }}
options: >-
--health-cmd="pg_isready || mysqladmin ping" --health-interval=5s

steps:
- uses: actions/checkout@v4

- uses: actions/download-artifact@v4
with:
pattern: wheels-*
path: dist/
merge-multiple: true

- uses: actions/setup-python@v5
with:
python-version: ${{ env.PRIMARY_PYTHON_VERSION }}

- name: Install built wheel and test dependencies
run: |
pip install dist/*.whl
pip install pytest pytest-asyncio

- name: Run integration tests
env:
BRIDGE_ORM_TEST_DATABASE_URL: ${{ matrix.database.url }}
run: pytest tests/python/integration/ -v --tb=short

publish_to_pypi:
name: Publish Wheels to PyPI
needs: python_integration_tests
runs-on: ubuntu-latest
if: github.event_name == 'release'
environment: pypi-release
permissions:
id-token: write

steps:
- uses: actions/download-artifact@v4
with:
pattern: wheels-*
path: dist/
merge-multiple: true

- uses: pypa/gh-action-pypi-publish@release/v1
21 changes: 18 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,25 @@ opentelemetry-semantic-conventions = "0.15"
opentelemetry-http = "0.12"
http = "1.1"
regex = "1"
jni = "0.21"
thiserror = "1.0"
arrow = "58"
arrow-ipc = "58"
dashmap = "5"

[dependencies.jni]
version = "0.21"
optional = true

[dependencies.arrow]
version = "58"
optional = true

[dependencies.arrow-ipc]
version = "58"
optional = true

[features]
default = []
data-science = ["arrow", "arrow-ipc"]
java-interop = ["jni"]

[dev-dependencies]
criterion = { version = "0.5", features = ["async_tokio"] }
Expand Down
6 changes: 4 additions & 2 deletions benches/baseline_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ async fn main() {
.execute(&pool)
.await
.unwrap();

let columns = vec![
("id".to_string(), "str".to_string(), false, true),
("username".to_string(), "str".to_string(), false, false),
Expand All @@ -37,7 +37,9 @@ async fn main() {

let start = Instant::now();
let filters = HashMap::new();
let rows = generic_query(&pool, None, url, "users", filters, Some(count as i64), None).await.unwrap();
let rows = generic_query(&pool, None, url, "users", filters, Some(count as i64), None)
.await
.unwrap();
let duration = start.elapsed();

println!("Fetched {} rows in Rust in {:?}", rows.len(), duration);
Expand Down
12 changes: 6 additions & 6 deletions benches/marshalling_bench.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use bridge_orm::engine::db::{connect, generic_query};
use bridge_orm::engine::metadata::register_entity;
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use sqlx::AnyPool;
use std::collections::HashMap;
use tokio::runtime::Runtime;
use sqlx::AnyPool;

async fn setup_rows(pool: &AnyPool, count: usize) {
for i in 0..count {
Expand All @@ -22,14 +22,14 @@ async fn setup_rows(pool: &AnyPool, count: usize) {
fn bench_rust_query(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let url = "sqlite::memory:";

rt.block_on(async {
let pool = connect(url).await.unwrap();
sqlx::query("CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT, email TEXT, created_at TEXT, updated_at TEXT)")
.execute(&pool)
.await
.unwrap();

// Register entity
let columns = vec![
("id".to_string(), "str".to_string(), false, true),
Expand All @@ -51,11 +51,11 @@ fn bench_rust_query(c: &mut Criterion) {
black_box(rows);
});
});

// 10000 rows
sqlx::query("DELETE FROM users").execute(&pool).await.unwrap();
setup_rows(&pool, 10000).await;

let pool_10000 = pool.clone();
c.bench_function("rust_fetch_10000", |b| {
b.to_async(&rt).iter(|| async {
Expand Down
74 changes: 72 additions & 2 deletions bridge_orm/core/query.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Any, AsyncIterator, Dict, List, Optional, Type

import bridge_orm_rs

from ..common.exceptions import DatabaseError, ValidationError


class EagerLoadingStrategy(Enum):
# WHY: Explicit enum prevents callers from passing magic strings like
# "joined" or "select_in" that would silently degrade to a default.
JOINED_FOR_TO_ONE = auto()
SELECT_IN_FOR_TO_MANY = auto()


@dataclass
class EagerLoadRequest:
"""
Describes a single relation the caller wants pre-loaded.

WHY: Using a dataclass instead of a raw dict forces callers to be
explicit about strategy — the compiler/type-checker enforces intent.
"""
relation_name: str
strategy: EagerLoadingStrategy


class Raw:
"""Wrapper for raw SQL expressions with bound parameters."""
__slots__ = ("sql", "params")
Expand All @@ -18,7 +39,7 @@ class QueryBuilder:
# Fluent interface for building and executing database queries.
# Rule: Use __slots__ on hot-path classes.

__slots__ = ("model_class", "_filters", "_limit", "_projection")
__slots__ = ("model_class", "_filters", "_limit", "_projection", "_eager_load_requests")

def __init__(self, model_class: Type["BaseModel"]) -> None:

Expand All @@ -30,6 +51,7 @@ def __init__(self, model_class: Type["BaseModel"]) -> None:
self._filters: Dict[str, Any] = {}
self._limit: Optional[int] = None
self._projection: Optional[List[str]] = None
self._eager_load_requests: List[EagerLoadRequest] = []

def select(self, *fields: str) -> "QueryBuilder":

Expand Down Expand Up @@ -70,6 +92,47 @@ def limit(self, count: int) -> "QueryBuilder":
self._limit = count
return self

def with_relation(
self,
relation_name: str,
strategy: EagerLoadingStrategy,
) -> "QueryBuilder":
"""
Registers a relation to be eagerly loaded using the specified strategy.

WHY: Fluent API allows chaining while each call mutates only the
eager-load list — a single, well-scoped responsibility.
"""
self._eager_load_requests.append(
EagerLoadRequest(
relation_name=relation_name,
strategy=strategy,
)
)
return self

def _build_query_ast_payload(self) -> dict:
"""
Converts internal builder state to a JSON-serialisable dict that
the Rust Query AST compiler understands.

WHY: Isolated as a private method so it can be tested independently
without triggering a real database call.
"""
return {
"table": self.model_class.table,
"filters": self._filters,
"limit": self._limit,
"projection": self._projection,
"eager_loads": [
{
"relation_name": request.relation_name,
"strategy": request.strategy.name,
}
for request in self._eager_load_requests
],
}

async def fetch(self, tx: Any = None) -> List[Any]:
"""
Execute the query and return all results as model instances.
Expand All @@ -84,8 +147,15 @@ async def fetch(self, tx: Any = None) -> List[Any]:
try:
# Handle Session or TxHandle
rs_tx = tx._rs_session if hasattr(tx, "_rs_session") else tx
eager_loads_payload = [
{
"relation_name": req.relation_name,
"strategy": req.strategy.name,
}
for req in self._eager_load_requests
]
raw_results = await bridge_orm_rs.fetch_all(
self.model_class.table, filters, self._limit, self._projection, tx=rs_tx
self.model_class.table, filters, self._limit, self._projection, eager_loads_payload, tx=rs_tx
)
instances = []
for res in raw_results:
Expand Down
8 changes: 8 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[advisories]
vulnerability = "deny"
unmaintained = "warn"
yanked = "deny"

[licenses]
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC"]
deny = ["GPL-2.0", "GPL-3.0", "AGPL-3.0", "LGPL-2.0", "LGPL-3.0"]
Loading
Loading