Metadata-Version: 2.4
Name: relix
Version: 0.1.0
Summary: A small ORM inspired by SQLAlchemy.
Author-email: Mizuki Hikaru <mizuki@hikaru.org>
License: MIT
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# relix

relix is a small SQLite ORM built around Python dataclasses. Models behave like ordinary dataclass objects while providing simple persistence, querying, foreign keys, transactions, datetime conversion, and JSON-backed container fields.

## Features

### Defining a model

Subclass `Model` and decorate the class with `@dataclass`.

```python
from dataclasses import dataclass

from relix import Model


@dataclass
class User(Model):
    name: str
    email: str
    is_admin: bool = False
```

Every model automatically has an optional integer `id`.

New objects start without an ID:

```python
user = User(
    name="Alice",
    email="alice@example.com",
)

print(user.id)  # None
```

The ID is assigned when the object is inserted into SQLite.

---

### Initializing a database

Call `Database.init()` after defining your models.

```python
from relix import Database

database = Database.init("app.db")
```

This registers the database with `Model` and creates tables for the currently registered models.

For an in-memory database:

```python
database = Database.init(":memory:")
```

Models should be defined before calling `Database.init()`:

```python
from dataclasses import dataclass

from relix import Database, Model


@dataclass
class User(Model):
    name: str


database = Database.init("app.db")
```

---

### Saving a model

Call `save()` to insert a new object.

```python
user = User(
    name="Alice",
    email="alice@example.com",
)

user.save()

print(user.id)
```

After insertion, `user.id` contains the SQLite row ID.

Calling `save()` again updates the existing row:

```python
user.name = "Alice Smith"
user.save()
```

---

### Getting a model by ID

Use `get()` to retrieve a row by primary key.

```python
user = User.get(42)

if user is not None:
    print(user.name)
```

`get()` returns `None` when the row does not exist.

---

### Getting all models

Use `all()` to retrieve every row.

```python
users = User.all()

for user in users:
    print(user.name)
```

---

### Filtering models

Use `where()` with model fields to build a query.

```python
admins = User.where(
    User.is_admin == True
).all()
```

Comparison operators can be used directly:

```python
User.where(User.id == 10)
User.where(User.id != 10)
User.where(User.id < 10)
User.where(User.id <= 10)
User.where(User.id > 10)
User.where(User.id >= 10)
```

`where()` returns a query object, so the database is not read until a result method such as `all()`, `first()`, or `count()` is called.

---

### Combining conditions

Use `&` for `AND`:

```python
users = User.where(
    (User.is_admin == True) &
    (User.name == "Alice")
).all()
```

Use `|` for `OR`:

```python
users = User.where(
    (User.name == "Alice") |
    (User.name == "Bob")
).all()
```

Multiple arguments to `where()` are also combined with `AND`:

```python
users = User.where(
    User.is_admin == True,
    User.name == "Alice",
).all()
```

---

### Getting the first result

Use `first()` when only one matching row is needed.

```python
user = User.where(
    User.email == "alice@example.com"
).first()

if user is not None:
    print(user.name)
```

`first()` returns `None` when no row matches.

---

### Counting rows

Use `count()` to count matching records without loading them.

```python
admin_count = User.where(
    User.is_admin == True
).count()
```

To count every row:

```python
user_count = User.where().count()
```

---

### Limiting queries

Use `limit()` to restrict the number of results.

```python
users = User.where(
    User.is_admin == True
).limit(10).all()
```

Query methods can be chained.

---

### Ordering queries

Pass a model field to `order_by()` for ascending order.

```python
users = User.where().order_by(
    User.name
).all()
```

Use `asc()` or `desc()` explicitly when needed:

```python
users = User.where().order_by(
    User.name.asc()
).all()
```

```python
users = User.where().order_by(
    User.name.desc()
).all()
```

Ordering and limiting can be combined:

```python
users = (
    User.where(User.is_admin == True)
    .order_by(User.name)
    .limit(10)
    .all()
)
```

---

### Deleting models

Call `delete()` on a saved model.

```python
user = User.get(42)

if user is not None:
    user.delete()
```

After deletion, the object's `id` is reset to `None`.

---

### Nullable fields

Use `Optional` for nullable columns.

```python
from dataclasses import dataclass
from typing import Optional

from relix import Model


@dataclass
class User(Model):
    name: str
    nickname: Optional[str] = None
```

Nullable values can be queried:

```python
users = User.where(
    User.nickname == None
).all()
```

and:

```python
users = User.where(
    User.nickname != None
).all()
```

These are translated into the appropriate SQLite `NULL` comparisons.

---

### Basic field types

relix maps common Python types to SQLite automatically.

```python
from dataclasses import dataclass

from relix import Model


@dataclass
class Example(Model):
    count: int
    enabled: bool
    score: float
    name: str
    contents: bytes
```

The corresponding SQLite storage types are selected automatically.

---

### Datetime fields

`datetime` values are stored as Unix timestamps.

```python
from dataclasses import dataclass
from datetime import datetime

from relix import Model


@dataclass
class Event(Model):
    name: str
    created_at: datetime


event = Event(
    name="Example",
    created_at=datetime.now(),
)

event.save()
```

When the row is loaded, the timestamp is converted back into a `datetime`.

Datetime values can also be used in queries:

```python
events = Event.where(
    Event.created_at >= datetime(2025, 1, 1)
).all()
```

SQLite stores datetime values as `REAL`, preserving fractional seconds.

---

### Dictionary fields

Fields typed as `dict` are automatically serialized as JSON.

```python
from dataclasses import dataclass

from relix import Model


@dataclass
class Document(Model):
    metadata: dict
```

Use them as ordinary dictionaries:

```python
document = Document(
    metadata={
        "author": "Alice",
        "published": True,
    },
)

document.save()
```

When the model is loaded, the JSON is converted back into a Python dictionary.

---

### List fields

Fields typed as `list` are automatically serialized as JSON too.

```python
from dataclasses import dataclass

from relix import Model


@dataclass
class Document(Model):
    tags: list
```

For example:

```python
document = Document(
    tags=[
        "python",
        "sqlite",
        "orm",
    ],
)

document.save()
```

---

### Dictionary and list subclasses

Subclasses of `dict` and `list` can be used as model fields.

```python
from dataclasses import dataclass

from relix import Model


class Settings(dict):
    pass


class Tags(list):
    pass


@dataclass
class Document(Model):
    settings: Settings
    tags: Tags
```

When a row is loaded, the values are reconstructed using the declared subclass:

```python
document = Document(
    settings=Settings({
        "theme": "dark",
    }),
    tags=Tags([
        "python",
        "sqlite",
    ]),
)

document.save()

loaded = Document.get(document.id)

print(type(loaded.settings))
print(type(loaded.tags))
```

---

### Foreign keys

Use `ForeignKey` to reference another model.

```python
from dataclasses import dataclass

from relix import ForeignKey, Model


@dataclass
class User(Model):
    name: str


@dataclass
class Post(Model):
    title: str
    author: ForeignKey[User]
```

Save the referenced object first:

```python
user = User(
    name="Alice",
)

user.save()

post = Post(
    title="Hello",
    author=user,
)

post.save()
```

SQLite stores the relationship using an integer foreign-key column.

For this example, the column is named:

```text
author_id
```

---

### Loading foreign keys

Foreign-key fields return the related model object.

```python
post = Post.get(1)

if post is not None:
    print(post.author.name)
```

The related object is loaded lazily when the relationship is first accessed.

The raw ID is also available:

```python
print(post.author_id)
```

This does not require loading the related `User`.

---

### Querying foreign keys

A foreign key can be queried using the related object.

```python
posts = Post.where(
    Post.author == user
).all()
```

The underlying ID column can also be queried directly:

```python
posts = Post.where(
    Post.author_id == user.id
).all()
```

---

### Nullable foreign keys

Use `Optional` for nullable relationships.

```python
from dataclasses import dataclass
from typing import Optional

from relix import ForeignKey, Model


@dataclass
class Employee(Model):
    name: str
    manager: Optional[ForeignKey["Employee"]] = None
```

A foreign-key value of `None` is stored as SQL `NULL`.

---

### Circular foreign keys

String foreign-key targets allow models to refer to classes that have not yet been defined.

A model can refer to itself:

```python
from dataclasses import dataclass
from typing import Optional

from relix import ForeignKey, Model


@dataclass
class Employee(Model):
    name: str
    manager: Optional[ForeignKey["Employee"]] = None
```

Two different models can also reference each other:

```python
@dataclass
class User(Model):
    name: str
    team: Optional[ForeignKey["Team"]] = None


@dataclass
class Team(Model):
    name: str
    owner: ForeignKey[User]
```

The target name is resolved against the models registered with `Model`.

---

### Transactions

Use `transaction()` when several operations must succeed or fail together.

```python
from relix import Database

database = Database.init("app.db")

with database.transaction():
    alice = User(
        name="Alice",
        email="alice@example.com",
    )
    alice.save()

    bob = User(
        name="Bob",
        email="bob@example.com",
    )
    bob.save()
```

If the block completes normally, the transaction is committed.

If an exception leaves the block, the transaction is rolled back.

---

### Accessing the database directly

`Database.init()` returns the configured database instance.

```python
from relix import Database

database = Database.init("app.db")
```

This gives application code access to lower-level database functionality such as transactions and explicit connection management when necessary.

---

### Model registration

Every `Model` subclass is registered automatically.

```python
from dataclasses import dataclass

from relix import Model


@dataclass
class User(Model):
    name: str


@dataclass
class Post(Model):
    title: str
```

The registered model classes are available through:

```python
Model.subclasses
```

relix uses this registry when creating tables and resolving string foreign-key references.

---

### DotDict

relix also provides `DotDict`, a dictionary with attribute access and mutation tracking.

```python
from relix import DotDict

settings = DotDict({
    "theme": "dark",
})

print(settings.theme)

settings.theme = "light"

print(settings.changed)
```

Dictionary-style access still works:

```python
settings["theme"] = "dark"
```

Call `mark_clean()` after persisting the value:

```python
settings.mark_clean()

print(settings.changed)
```

Nested dictionaries are wrapped so nested changes can also be tracked:

```python
settings.profile = {
    "name": "Alice",
}

settings.mark_clean()

settings.profile.name = "Alice Smith"

print(settings.changed)
```

`DotDict` is useful when an application needs to know whether mutable dictionary state actually needs to be persisted.

---

### A small application

A complete relix application can stay very small.

```python
from dataclasses import dataclass
from datetime import datetime
from typing import Optional

from relilx import Database, ForeignKey, Model


@dataclass
class User(Model):
    username: str
    display_name: str


@dataclass
class Post(Model):
    author: ForeignKey[User]
    title: str
    body: str
    created_at: datetime


database = Database.init("app.db")


alice = User(
    username="alice",
    display_name="Alice",
)

alice.save()


post = Post(
    author=alice,
    title="Hello",
    body="My first post.",
    created_at=datetime.now(),
)

post.save()


posts = (
    Post.where(Post.author == alice)
    .order_by(Post.created_at.desc())
    .all()
)

for post in posts:
    print(post.title)
```

relix deliberately keeps its scope small. It provides straightforward dataclass persistence and querying without attempting to reproduce the feature set or abstraction level of a larger ORM.
