Skip to main content

A modern Python ORM framework with dataclass support

Project description

Norma ORM

A modern Python ORM framework with dataclass support, providing type-safe database operations across PostgreSQL, SQLite, MongoDB, and Cassandra.

Python 3.8+ License: MIT GitHub Stars GitHub Issues

🚀 Features

  • 🎯 Type-Safe: Built with modern Python type hints and full mypy support
  • 🏗️ Dataclass-Based: Define models using Python dataclasses with automatic validation
  • 🔄 Multi-Database: Unified interface for PostgreSQL, SQLite, MongoDB, and Cassandra
  • 📊 Schema Generation: Automatic Pydantic schema generation for APIs
  • Async/Sync: Support for both asynchronous and synchronous operations
  • 🛠️ CLI Tools: Powerful command-line interface for project initialization and code generation
  • 🔍 Query Builder: Intuitive query syntax with support for complex filters
  • 🔒 Validation: Built-in field validation with customizable constraints
  • 📖 Relationships: Support for one-to-one, one-to-many, and many-to-many relationships
  • 🎨 Developer Experience: Rich error messages and comprehensive debugging tools

📋 Table of Contents

📦 Installation

Basic Installation

pip install norma-orm

Database-Specific Dependencies

# For PostgreSQL support
pip install norma-orm[postgres]

# For Cassandra support
pip install norma-orm[cassandra]

# For all development tools
pip install norma-orm[dev]

# For CLI tools with rich output
pip install norma-orm[cli]

# Install everything
pip install norma-orm[postgres,cassandra,dev,cli]

From Source

git clone https://github.com/Geoion/Norma.git
cd Norma
pip install -e .

⚡ Quick Start

1. Define Your Models

from dataclasses import dataclass
from typing import Optional
from datetime import datetime
from norma import BaseModel, Field

@dataclass
class User(BaseModel):
    # Primary key with auto-generation
    id: str = Field(
        primary_key=True,
        default_factory=lambda: __import__('uuid').uuid4().hex,
        description="Unique user identifier"
    )
    
    # Required fields with validation
    name: str = Field(
        max_length=100,
        min_length=1,
        index=True,
        description="User's full name"
    )
    
    email: str = Field(
        unique=True,
        max_length=255,
        regex_pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
        description="User's email address"
    )
    
    # Optional fields with defaults
    age: int = Field(
        default=0,
        min_value=0,
        max_value=150,
        description="User's age in years"
    )
    
    is_active: bool = Field(
        default=True,
        index=True,
        description="Whether the user account is active"
    )
    
    created_at: Optional[datetime] = Field(
        default_factory=datetime.now,
        description="Account creation timestamp"
    )

2. Initialize the Client

from norma import NormaClient

# SQLite (for development)
client = NormaClient(
    adapter_type="sql",
    database_url="sqlite:///./app.db"
)

# PostgreSQL (for production)
client = NormaClient(
    adapter_type="sql",
    database_url="postgresql://user:password@localhost:5432/mydb"
)

# MongoDB
client = NormaClient(
    adapter_type="mongo",
    database_url="mongodb://localhost:27017",
    database_name="myapp"
)

# Cassandra
client = NormaClient(
    adapter_type="cassandra",
    database_url="127.0.0.1",
    keyspace="myapp_keyspace"
)

3. Basic CRUD Operations

import asyncio

async def main():
    async with client:
        # Get model client
        user_client = client.get_model_client(User)
        
        # Create table/collection
        await user_client.create_table()
        
        # Create a user
        user = User(
            name="John Doe",
            email="john@example.com",
            age=30
        )
        created_user = await user_client.insert(user)
        print(f"Created: {created_user}")
        
        # Find by ID
        found_user = await user_client.find_by_id(created_user.id)
        print(f"Found: {found_user}")
        
        # Update user
        found_user.age = 31
        updated_user = await user_client.update(found_user)
        print(f"Updated: {updated_user}")
        
        # Query users
        adults = await user_client.find_many({"age": {"$gte": 18}})
        print(f"Adults: {len(adults)}")
        
        # Delete user
        deleted = await user_client.delete_by_id(created_user.id)
        print(f"Deleted: {deleted}")

# Run the example
asyncio.run(main())

🏗️ Models

Defining Models

Models in Norma are Python dataclasses that inherit from BaseModel:

from dataclasses import dataclass
from typing import Optional, List
from norma import BaseModel, Field, OneToMany, ManyToOne

@dataclass
class Author(BaseModel):
    id: str = Field(primary_key=True, default_factory=lambda: __import__('uuid').uuid4().hex)
    name: str = Field(max_length=100, index=True)
    email: str = Field(unique=True)
    bio: Optional[str] = Field(max_length=1000, default=None)

@dataclass
class Post(BaseModel):
    id: str = Field(primary_key=True, default_factory=lambda: __import__('uuid').uuid4().hex)
    title: str = Field(max_length=200, index=True)
    content: str = Field(min_length=1)
    published: bool = Field(default=False, index=True)
    
    # Foreign key relationship
    author_id: str = Field(
        relationship=ManyToOne("Author", foreign_key="id"),
        description="ID of the post author"
    )
    
    created_at: datetime = Field(default_factory=datetime.now, index=True)

Field Configuration

The Field() function provides extensive configuration options:

from norma import Field

# Basic field types
name: str = Field()  # Simple string field
age: int = Field(default=0)  # With default value
active: bool = Field(default=True)

# Validation constraints
email: str = Field(
    unique=True,                    # Unique constraint
    max_length=255,                 # Maximum string length
    min_length=5,                   # Minimum string length
    regex_pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$"  # Regex validation
)

price: float = Field(
    min_value=0.0,                  # Minimum numeric value
    max_value=999999.99,            # Maximum numeric value
    default=0.0
)

# Database options
user_id: str = Field(
    primary_key=True,               # Primary key
    index=True,                     # Create database index
    nullable=False,                 # Not nullable
    db_column_name="user_uuid",     # Custom column name
    db_type="UUID"                  # Custom database type
)

# Documentation
description: str = Field(
    max_length=500,
    description="User-friendly field description"
)

Relationships

Norma supports various relationship types:

from norma import OneToOne, OneToMany, ManyToOne, ManyToMany

@dataclass
class User(BaseModel):
    id: str = Field(primary_key=True)
    name: str = Field()

@dataclass
class Profile(BaseModel):
    id: str = Field(primary_key=True)
    user_id: str = Field(relationship=OneToOne("User"))
    bio: str = Field()

@dataclass
class Post(BaseModel):
    id: str = Field(primary_key=True)
    author_id: str = Field(relationship=ManyToOne("User"))
    title: str = Field()

@dataclass
class Tag(BaseModel):
    id: str = Field(primary_key=True)
    name: str = Field()
    post_ids: List[str] = Field(relationship=ManyToMany("Post"))

💾 Database Operations

Basic CRUD

# Create
user = User(name="Alice", email="alice@example.com")
created_user = await user_client.insert(user)

# Read
user = await user_client.find_by_id("user_id")
users = await user_client.find_many()

# Update
user.age = 25
updated_user = await user_client.update(user)

# Delete
deleted = await user_client.delete_by_id("user_id")

Advanced Queries

# Filter by single field
active_users = await user_client.find_many({"is_active": True})

# Multiple filters
adult_active_users = await user_client.find_many({
    "age": {"$gte": 18},
    "is_active": True
})

# Comparison operators
users = await user_client.find_many({
    "age": {"$gte": 18, "$lte": 65},  # Between 18 and 65
    "name": {"$ne": "Admin"},         # Not equal to "Admin"
    "email": {"$in": ["user1@test.com", "user2@test.com"]}  # In list
})

# Pagination and sorting
users = await user_client.find_many(
    filters={"is_active": True},
    limit=10,
    offset=20,
    order_by=["-created_at", "name"]  # Desc by created_at, asc by name
)

# Count records
total_users = await user_client.count()
active_users_count = await user_client.count({"is_active": True})

# Check existence
user_exists = await user_client.exists({"email": "test@example.com"})

Synchronous Operations

For scenarios where you need synchronous operations:

# Synchronous client usage
with client:
    user_client = client.get_model_client(User)
    
    # Synchronous operations
    user = User(name="Sync User", email="sync@example.com")
    created_user = user_client.insert_sync(user)
    
    found_user = user_client.find_by_id_sync(created_user.id)
    
    users = user_client.find_many_sync({"is_active": True})

📊 Schema Generation

Norma automatically generates Pydantic schemas for API integration:

from norma.schema import generate_schemas

# Generate all schemas
schemas = generate_schemas(User)

CreateUserSchema = schemas['create']  # For input validation
ReadUserSchema = schemas['read']      # For output serialization  
UpdateUserSchema = schemas['update']  # For partial updates

# Use with FastAPI
from fastapi import FastAPI

app = FastAPI()

@app.post("/users/", response_model=ReadUserSchema)
async def create_user(user_data: CreateUserSchema):
    # Convert schema to model
    user = User.from_dict(user_data.model_dump())
    created_user = await user_client.insert(user)
    return created_user.to_dict()

@app.patch("/users/{user_id}", response_model=ReadUserSchema)
async def update_user(user_id: str, user_data: UpdateUserSchema):
    # Find existing user
    user = await user_client.find_by_id(user_id)
    if not user:
        raise HTTPException(status_code=404)
    
    # Apply updates
    update_data = user_data.model_dump(exclude_none=True)
    user.update(**update_data)
    
    updated_user = await user_client.update(user)
    return updated_user.to_dict()

🛠️ CLI Tools

Project Initialization

# Create a new project
norma init my-project

# With specific template and database
norma init my-project --template fastapi --database postgresql

# Available templates: basic, fastapi, django
# Available databases: sqlite, postgresql, mongodb, cassandra

This creates a complete project structure:

my-project/
├── models/
│   ├── __init__.py
│   ├── user.py
│   └── post.py
├── schemas/
│   └── __init__.py
├── config/
│   ├── __init__.py
│   └── database.py
├── main.py
├── requirements.txt
└── README.md

Schema Generation

# Generate Pydantic schemas from models
norma generate --models ./models --output ./schemas

# Watch for changes and auto-regenerate
norma generate --models ./models --output ./schemas --watch

# Different output formats
norma generate --models ./models --output ./schemas --format pydantic

Version Information

norma version

⚙️ Configuration

Database Configuration

# config/database.py
import os

DATABASE_CONFIG = {
    "type": "postgresql",
    "url": os.getenv("DATABASE_URL", "postgresql://user:pass@localhost/db"),
    "echo": os.getenv("DB_ECHO", "false").lower() == "true",
    "pool_size": int(os.getenv("DB_POOL_SIZE", "5")),
    "max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "10")),
}

# For MongoDB
MONGODB_CONFIG = {
    "url": os.getenv("MONGODB_URL", "mongodb://localhost:27017"),
    "database_name": os.getenv("MONGODB_DB", "myapp"),
    "server_selection_timeout": 5000,
    "max_pool_size": 100,
}

Environment Variables

# .env file
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
DB_ECHO=false
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20

# For MongoDB
MONGODB_URL=mongodb://localhost:27017
MONGODB_DB=myapp

# For Cassandra
CASSANDRA_HOSTS=127.0.0.1,127.0.0.2,127.0.0.3
CASSANDRA_KEYSPACE=myapp_keyspace
CASSANDRA_PORT=9042
CASSANDRA_USERNAME=cassandra
CASSANDRA_PASSWORD=cassandra

Client Configuration

# Advanced client configuration
client = NormaClient(
    adapter_type="sql",
    database_url="postgresql://user:pass@localhost/db",
    echo=True,              # Log SQL queries
    pool_size=10,           # Connection pool size
    max_overflow=20,        # Max overflow connections
    pool_timeout=30,        # Pool timeout in seconds
    pool_recycle=3600,      # Recycle connections after 1 hour
)

# Cassandra client configuration
cassandra_client = NormaClient(
    adapter_type="cassandra",
    database_url="127.0.0.1,127.0.0.2,127.0.0.3",
    keyspace="myapp_keyspace",
    port=9042,
    username="cassandra",
    password="cassandra",
    protocol_version=4,
    connect_timeout=10,
    request_timeout=10,
)

🚀 Advanced Usage

Custom Validation

from norma.exceptions import ValidationError

@dataclass
class User(BaseModel):
    email: str = Field(unique=True)
    age: int = Field(min_value=0, max_value=150)
    
    def __post_init__(self):
        super().__post_init__()  # Call parent validation
        
        # Custom validation logic
        if self.age < 13 and "@" not in self.email:
            raise ValidationError("Users under 13 must have a valid email")
        
        # Domain-specific validation
        if self.email.endswith("@competitor.com"):
            raise ValidationError("Competitor emails not allowed")

Error Handling

from norma.exceptions import (
    NormaError, ValidationError, NotFoundError, 
    DuplicateError, ConnectionError
)

try:
    user = User(name="", email="invalid-email")  # Will raise ValidationError
    await user_client.insert(user)
except ValidationError as e:
    print(f"Validation failed: {e.message}")
    print(f"Field: {e.field}, Value: {e.value}")

except DuplicateError as e:
    print(f"Duplicate entry: {e.message}")

except NotFoundError as e:
    print(f"Not found: {e.message}")

except ConnectionError as e:
    print(f"Database connection failed: {e.message}")

except NormaError as e:
    print(f"Norma error: {e.message}")
    print(f"Details: {e.details}")

📚 API Reference

BaseModel

The base class for all Norma models.

class BaseModel:
    def validate(self) -> None:
        """Validate the model according to field configurations."""
    
    def to_dict(self, exclude_none: bool = True, exclude_private: bool = True) -> Dict[str, Any]:
        """Convert model to dictionary."""
    
    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> 'BaseModel':
        """Create model from dictionary."""
    
    def update(self, **kwargs) -> None:
        """Update model fields with validation."""
    
    @classmethod
    def get_primary_key_field(cls) -> Optional[str]:
        """Get the primary key field name."""
    
    @classmethod
    def get_unique_fields(cls) -> List[str]:
        """Get all unique field names."""
    
    def is_persisted(self) -> bool:
        """Check if model has been saved to database."""

NormaClient

Main client for database operations.

class NormaClient:
    def __init__(self, adapter_type: str, database_url: str, **kwargs):
        """Initialize client with database configuration."""
    
    async def connect(self) -> None:
        """Connect to database."""
    
    async def disconnect(self) -> None:
        """Disconnect from database."""
    
    def get_model_client(self, model_class: Type[BaseModel]) -> ModelClient:
        """Get client for specific model."""
    
    # Context manager support
    async def __aenter__(self): ...
    async def __aexit__(self, exc_type, exc_val, exc_tb): ...

ModelClient

Client for operations on a specific model.

class ModelClient:
    async def insert(self, model: BaseModel) -> BaseModel:
        """Insert new record."""
    
    async def update(self, model: BaseModel) -> BaseModel:
        """Update existing record."""
    
    async def find_by_id(self, id_value: Any) -> Optional[BaseModel]:
        """Find record by primary key."""
    
    async def find_many(self, filters: Dict = None, limit: int = None, 
                       offset: int = None, order_by: List[str] = None) -> List[BaseModel]:
        """Find multiple records."""
    
    async def delete_by_id(self, id_value: Any) -> bool:
        """Delete record by primary key."""
    
    async def count(self, filters: Dict = None) -> int:
        """Count matching records."""
    
    async def exists(self, filters: Dict) -> bool:
        """Check if records exist."""

🔧 Troubleshooting

Common Issues

  1. Import Errors

    pip install norma-orm[dev]  # Make sure all dependencies are installed
    
  2. Database Connection Issues

    # Test your connection string
    client = NormaClient(adapter_type="sql", database_url="your_url_here")
    try:
        await client.connect()
        print("Connection successful!")
    except ConnectionError as e:
        print(f"Connection failed: {e}")
    
  3. Validation Errors

    # Enable detailed error reporting
    import logging
    logging.getLogger('norma').setLevel(logging.DEBUG)
    
  4. Performance Issues

    # Optimize queries with indexes
    name: str = Field(index=True)  # Add indexes to frequently queried fields
    
    # Use pagination for large datasets
    users = await user_client.find_many(limit=100, offset=0)
    

🤝 Contributing

We welcome contributions! Here's how you can help:

Development Setup

# Clone the repository
git clone https://github.com/Geoion/Norma.git
cd Norma

# Create virtual environment
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install development dependencies
pip install -e .[dev]

# Run tests
pytest

# Run linting
black .
isort .
mypy norma/

Contribution Guidelines

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Write tests for your changes
  4. Ensure tests pass and code is formatted
  5. Commit your changes (git commit -m 'Add amazing feature')
  6. Push to your branch (git push origin feature/amazing-feature)
  7. Open a Pull Request

Reporting Issues

When reporting issues, please include:

  • Python version
  • Norma version (norma version)
  • Database type and version
  • Minimal code example
  • Full error traceback

📝 Changelog

v0.1.1 (2025-09-19)

🚀 New Features

  • Enhanced Test Coverage: Added comprehensive test suite with 25+ tests covering core functionality
  • Performance Optimization Tools: Added QueryOptimizer for query analysis and performance monitoring
  • Database Migrations: Introduced MigrationManager for database schema versioning
  • Relationship Management: Added RelationshipManager for handling model relationships and lazy loading
  • Improved CLI: Enhanced schema generation with dynamic model discovery

🔧 Bug Fixes

  • SQL Adapter: Fixed SQLite connection pool parameter issues
  • Schema Generation: Fixed update schema primary key validation requirements
  • Query Filters: Enhanced support for complex MongoDB-style query operators ($gte, $lte, etc.)
  • Field Validation: Improved dataclass field ordering for proper initialization

🧪 Testing & Quality

  • Comprehensive Test Suite: 25 tests covering adapters, schema generation, and core functionality
  • CI/CD Ready: All tests pass with proper error handling and validation
  • Code Quality: Enhanced type safety and documentation coverage

📚 Documentation

  • Contributing Guide: Added detailed CONTRIBUTING.md with development setup
  • Advanced Examples: Added comprehensive blog example with relationships
  • Performance Guide: Documentation for query optimization features

🛠️ Developer Experience

  • Better Error Messages: Enhanced exception handling with detailed error context
  • CLI Improvements: Project initialization with multiple templates
  • Development Tools: Added file watching for schema generation

v0.1.0 (2025-06-01)

🎉 Initial Release

  • Core ORM Features: Type-safe dataclass-based models with validation
  • Multi-Database Support: PostgreSQL, SQLite, MongoDB, and Cassandra adapters
  • Async/Sync Operations: Full support for both asynchronous and synchronous database operations
  • Pydantic Integration: Automatic schema generation for API development
  • CLI Tools: Project initialization and code generation utilities
  • Field Validation: Comprehensive field constraints and validation rules
  • Query Builder: Intuitive query syntax with filtering and pagination

📄 License

This project is licensed under the MIT License - see the LICENSE file for details.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

norma_orm-0.1.1.tar.gz (63.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

norma_orm-0.1.1-py3-none-any.whl (53.7 kB view details)

Uploaded Python 3

File details

Details for the file norma_orm-0.1.1.tar.gz.

File metadata

  • Download URL: norma_orm-0.1.1.tar.gz
  • Upload date:
  • Size: 63.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.0

File hashes

Hashes for norma_orm-0.1.1.tar.gz
Algorithm Hash digest
SHA256 ac69e437773c55fe745c9b16cffa228a00b18754b57427373638bededf04471c
MD5 fbf3ad58e3ccb19bf52b209dc6f096b6
BLAKE2b-256 eec1fb85cade72f612ddc3ae494bdc074e3159c2e278bddc5f79e46777ae07f4

See more details on using hashes here.

File details

Details for the file norma_orm-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: norma_orm-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 53.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.0

File hashes

Hashes for norma_orm-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 2726f292458bf8b06f3ef39ab0724cac0323c093268e2eded55fad99e316166a
MD5 922b0f5160019668088c55f7bcaea8a9
BLAKE2b-256 d1fca538dd1560fe457d46cd8b30a4c1bdba9e52f3c51dc2c898dfc07550da2c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page