Skip to content
Open
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
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DATABASE_URL=postgresql://pguser:pgpassword@localhost:10011/postgres
Copy link
Contributor

@MaximSrour MaximSrour Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely do not commit a .env file.

If you need to rely on a .env file, create a template (e.g., .env.example) and add .env into the gitignore.

It would also require some form of documentation in the README to explain the purpose of this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the note, I didn’t notice that the original project did not have a preset ignoring env files

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me personally, this functionality is necessary to build a system built for data analysis in a company. I have many different users who have their own schemes with their own rights. I would like to give them access only to their schemas, without generating configs for each of them
I also saw the issues in discussions about schema support, but no one promoted it

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ profile.py
*.db-journal
*coverage.xml
.benchmarks/
.env
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,29 @@ for func in [
base_ormar_config.metadata.drop_all(base_ormar_config.engine)
```


### Include schema in model

You can also include schema in model configuration like this:

```python
class HrEmployees(ormar.Model):
ormar_config = base_ormar_config.copy(tablename="employees", schema="Hr")

id = ormar.Integer(primary_key=True)
name = ormar.String(max_length=200)

class ItEmployees(ormar.Model):
ormar_config = base_ormar_config.copy(tablename="employees", schema="It")

id = ormar.Integer(primary_key=True)
name = ormar.String(max_length=200)

# note This allows you to have multiple tables with the same name (employees) in different schemas (Hr.employees and It.employees), which is particularly useful for logical data separation—e.g., when modeling department-specific entities while avoiding naming collisions.
```



## Ormar Specification

### QuerySet methods
Expand Down
3 changes: 3 additions & 0 deletions ormar/fields/foreign_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ def populate_fk_params_based_on_to_model(
fk_string = (
to.ormar_config.tablename + "." + to.get_column_alias(to.ormar_config.pkname)
)
if to.ormar_config.schema:
fk_string = to.ormar_config.schema + "." + fk_string

to_field = to.ormar_config.model_fields[to.ormar_config.pkname]
pk_only_model = create_dummy_model(to, to_field)
__type__ = (
Expand Down
1 change: 1 addition & 0 deletions ormar/fields/many_to_many.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ def create_default_through_model(self) -> None:
tablename=table_name,
database=self.owner.ormar_config.database,
metadata=self.owner.ormar_config.metadata,
schema=self.owner.ormar_config.schema
)
through_model = type(
class_name,
Expand Down
14 changes: 11 additions & 3 deletions ormar/models/helpers/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def adjust_through_many_to_many_model(model_field: "ManyToManyField") -> None:
real_name=parent_name,
ondelete="CASCADE",
owner=model_field.through,
)
)

model_fields[child_name] = ormar.ForeignKey( # type: ignore
model_field.owner,
Expand Down Expand Up @@ -81,11 +81,18 @@ def create_and_append_m2m_fk(
raise ormar.ModelDefinitionError(
"ManyToMany relation cannot lead to field without pk"
)

column = sqlalchemy.Column(
field_name,
pk_column.type,
sqlalchemy.schema.ForeignKey(
model.ormar_config.tablename + "." + pk_alias,
model.ormar_config.tablename + "." + pk_alias
if not model.ormar_config.schema
else model.ormar_config.schema
+ "."
+ model.ormar_config.tablename
+ "."
+ pk_alias,
ondelete="CASCADE",
onupdate="CASCADE",
name=f"fk_{model_field.through.ormar_config.tablename}_{model.ormar_config.tablename}"
Expand Down Expand Up @@ -283,8 +290,9 @@ def populate_config_sqlalchemy_table_if_required(config: "OrmarConfig") -> None:
config=config
):
set_constraint_names(config=config)
schema_name = getattr(config, "schema", None) or None
table = sqlalchemy.Table(
config.tablename, config.metadata, *config.columns, *config.constraints
config.tablename, config.metadata, schema=schema_name, *config.columns, *config.constraints
)
config.table = table

Expand Down
1 change: 1 addition & 0 deletions ormar/models/metaclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ def copy_and_replace_m2m_through_model( # noqa: CFQ002
through_class = field.through
new_config = ormar.OrmarConfig(
tablename=through_class.ormar_config.tablename,
schema=through_class.ormar_config.schema,
metadata=through_class.ormar_config.metadata,
database=through_class.ormar_config.database,
abstract=through_class.ormar_config.abstract,
Expand Down
5 changes: 5 additions & 0 deletions ormar/models/ormar_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class OrmarConfig:
metadata: sqlalchemy.MetaData
database: databases.Database
tablename: str
schema: str
order_by: List[str]
abstract: bool
exclude_parent_fields: List[str]
Expand All @@ -30,6 +31,7 @@ def __init__(
database: Optional[databases.Database] = None,
engine: Optional[sqlalchemy.engine.Engine] = None,
tablename: Optional[str] = None,
schema: Optional[str] = None,
order_by: Optional[List[str]] = None,
abstract: bool = False,
queryset_class: Type[QuerySet] = QuerySet,
Expand All @@ -41,6 +43,7 @@ def __init__(
self.database = database # type: ignore
self.engine = engine # type: ignore
self.tablename = tablename # type: ignore
self.schema = schema # type: ignore
self.orders_by = order_by or []
self.columns: List[sqlalchemy.Column] = []
self.constraints = constraints or []
Expand All @@ -62,6 +65,7 @@ def copy(
database: Optional[databases.Database] = None,
engine: Optional[sqlalchemy.engine.Engine] = None,
tablename: Optional[str] = None,
schema: Optional[str] = None,
order_by: Optional[List[str]] = None,
abstract: Optional[bool] = None,
queryset_class: Optional[Type[QuerySet]] = None,
Expand All @@ -73,6 +77,7 @@ def copy(
database=database or self.database,
engine=engine or self.engine,
tablename=tablename,
schema=schema or self.schema,
order_by=order_by,
abstract=abstract or self.abstract,
queryset_class=queryset_class or self.queryset_class,
Expand Down
20 changes: 19 additions & 1 deletion tests/lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,29 @@ async def do_lifespan(_: FastAPI) -> AsyncIterator[None]:
def init_tests(config, scope="module"):
@pytest.fixture(autouse=True, scope=scope)
def create_database():
config.engine = sqlalchemy.create_engine(config.database.url._url)

engine = sqlalchemy.create_engine(config.database.url._url)

# Собираем все уникальные схемы, которые указаны в моделях
schemas = set()
for table in config.metadata.tables.values():
if table.schema:
schemas.add(table.schema)

# Создаем схемы
with engine.begin() as conn:
for schema in schemas:
conn.execute(sqlalchemy.text(f"CREATE SCHEMA IF NOT EXISTS {schema}"))

config.engine = engine
config.metadata.create_all(config.engine)

yield

config.metadata.drop_all(config.engine)

with engine.begin() as conn:
for schema in schemas:
conn.execute(sqlalchemy.text(f"DROP SCHEMA IF EXISTS {schema} CASCADE"))

return create_database
1 change: 0 additions & 1 deletion tests/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
database_url = databases.DatabaseURL(DATABASE_URL)
if database_url.scheme == "postgresql+aiopg": # pragma no cover
DATABASE_URL = str(database_url.replace(driver=None))
print("USED DB:", DATABASE_URL)


def create_config(**args):
Expand Down
Empty file added tests/test_schema/__init__.py
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import ormar
import pytest
import sqlalchemy

from tests.lifespan import init_tests
from tests.settings import create_config

base_ormar_config = create_config()


class User(ormar.Model):
ormar_config = base_ormar_config.copy(tablename="users", schema='s1')

id = ormar.Integer(primary_key=True)

class Profile(ormar.Model):
ormar_config = base_ormar_config.copy(tablename="profiles", schema='s2')

id = ormar.Integer(primary_key=True)
user = ormar.ForeignKey(User)

create_test_database = init_tests(base_ormar_config)

@pytest.mark.asyncio
async def test_fk_cross_schema():
async with base_ormar_config.database:
insp = sqlalchemy.inspect(base_ormar_config.engine)
fks = insp.get_foreign_keys("profiles", schema="s2")

assert fks[0]["referred_schema"] == "s1"
assert fks[0]["referred_table"] == "users"


26 changes: 26 additions & 0 deletions tests/test_schema/test_create_table_on_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import ormar
import pytest
import sqlalchemy

from tests.lifespan import init_tests
from tests.settings import create_config

base_ormar_config = create_config()


class Category(ormar.Model):
ormar_config = base_ormar_config.copy(tablename="categories", schema='test')

id: int = ormar.Integer(primary_key=True)
name: str = ormar.String(max_length=50, unique=True, index=True)
code: int = ormar.Integer()


create_test_database = init_tests(base_ormar_config)


@pytest.mark.asyncio
async def test_table_in_schema():
async with base_ormar_config.database:
insp = sqlalchemy.inspect(base_ormar_config.engine)
assert insp.has_table("categories", schema="test")
32 changes: 32 additions & 0 deletions tests/test_schema/test_create_table_with_fk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import ormar
import pytest
import sqlalchemy

from tests.lifespan import init_tests
from tests.settings import create_config

base_ormar_config = create_config()


class User(ormar.Model):
ormar_config = base_ormar_config.copy(tablename="users", schema='s1')

id = ormar.Integer(primary_key=True)

class Profile(ormar.Model):
ormar_config = base_ormar_config.copy(tablename="profiles", schema='s1')

id = ormar.Integer(primary_key=True)
user = ormar.ForeignKey(User)

create_test_database = init_tests(base_ormar_config)

@pytest.mark.asyncio
async def test_fk_resolves_schema():
async with base_ormar_config.database:
insp = sqlalchemy.inspect(base_ormar_config.engine)
fks = insp.get_foreign_keys("profiles", schema="s1")

assert len(fks) == 1
assert fks[0]["referred_table"] == "users"
assert fks[0]["referred_schema"] == "s1"
75 changes: 75 additions & 0 deletions tests/test_schema/test_filter_with_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from typing import Optional

import ormar
import pytest

from tests.lifespan import init_tests
from tests.settings import create_config

base_ormar_config = create_config()


class Author(ormar.Model):
ormar_config = base_ormar_config.copy(tablename="authors", schema='s1')

id = ormar.Integer(primary_key=True)
name = ormar.String(max_length=100)


class Book(ormar.Model):
ormar_config = base_ormar_config.copy(tablename="books", schema='s2')

id = ormar.Integer(primary_key=True)
title = ormar.String(max_length=100)
author: Optional[Author] = ormar.ForeignKey(Author)


create_test_database = init_tests(base_ormar_config)


@pytest.mark.asyncio
async def test_filter_with_schema():
async with base_ormar_config.database:
author1 = await Author.objects.create(name="Tolkien")
author2 = await Author.objects.create(name="Rowling")

await Book.objects.create(title="LOTR", author=author1)
await Book.objects.create(title="Hobbit", author=author1)
await Book.objects.create(title="HP", author=author2)

# фильтрация через join к таблице в другой схеме
books = await Book.objects.filter(author__name="Tolkien").all()

titles = sorted(b.title for b in books)
assert titles == ["Hobbit", "LOTR"]


@pytest.mark.asyncio
async def test_filter_with_schema_and_in_lookup():
async with base_ormar_config.database:
author1 = await Author.objects.create(name="Asimov")
author2 = await Author.objects.create(name="Clarke")

await Book.objects.create(title="Foundation", author=author1)
await Book.objects.create(title="Robots", author=author1)
await Book.objects.create(title="Odyssey", author=author2)

books = await Book.objects.filter(
author__name__in=["Asimov"]
).all()

assert {b.title for b in books} == {"Foundation", "Robots"}


@pytest.mark.asyncio
async def test_filter_with_schema_and_null_fk():
async with base_ormar_config.database:
author = await Author.objects.create(name="Orphan Author")

await Book.objects.create(title="With Author", author=author)
await Book.objects.create(title="Without Author", author=None)

books = await Book.objects.filter(author__isnull=True).all()

assert len(books) == 1
assert books[0].title == "Without Author"
Loading
Loading