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
13 changes: 8 additions & 5 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ Optionally:

All optional parameters can be omitted if not required.

Each schema class must inherit from `BaseSchema` and must be registered using `register_schema()`. An example of a schema to store road bikes is given below.
Each schema class must inherit from `BaseSchema` and must be registered using the `@polynom_schema` decorator. An example of a schema to store road bikes is given below.

```python
from polynom.schema.schema_registry import register_schema
from polynom.schema.schema_registry import polynom_schema
from polynom.schema.field import Field
from polynom.schema.polytypes import VarChar, Decimal
from polynom.schema.schema import BaseSchema

@polynom_schema
class BikeSchema(BaseSchema):
namespace_name = 'vehicles' # optional
entity_name = 'Bike'
Expand All @@ -49,8 +50,6 @@ class BikeSchema(BaseSchema):
Field('price', Decimal())
]

# Make schemas discoverable by PolyNOM
register_schema(BikeSchema)
```
It can be observed that no primary key is defined in this schema. This is permitted as PolyNOM automatically creates a entry identifier of very high probabilistic uniqueness for each inserted entry. Thereby the probability to find a duplicate within 103 trillion entries is one in a billion. This identifier field is not listed in the schema but is accessible on the corresponding model class. The use of these internal identifiers as an alternative to autoincrement or manualy managed id fields is strongly encouraged.

Expand All @@ -64,12 +63,16 @@ A model represents a single entry for a given schema. Each model must:

Further each model offers the field `_entry_id` which returns a unique identifier for the given entry. These identifiers are handled by PolyNOM and should not be modified or set manually.

In contrast to schemas, registering models is optional. Models can be registered using the `@polynom_model` decorator. This allows them to be resolved by relationships using their fully qualified name string.

The following model matches our `BikeSchema` from step one.

```python
from polynom.model import BaseModel
from polynom.model.model import BaseModel
from polynom.model.model_registry import polynom_model
from myproject.bike.model import Bike

@polynom_model
class Bike(BaseModel):
schema = BikeSchema()

Expand Down
48 changes: 42 additions & 6 deletions docs/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ Schema-level references define how entities relate at the database level. These
First, a simple schema for the `Owner` id created. No references are defined here yet.

```python
from polynom.schema.schema_registry import register_schema
from polynom.schema.schema_registry import polynom_schema
from polynom.schema.field import Field
from polynom.schema.polytypes import VarChar
from polynom.schema.schema import BaseSchema

@polynom_schema
class OwnerSchema(BaseSchema):
namespace_name = 'users'
entity_name = 'Owner'
Expand All @@ -34,8 +35,6 @@ class OwnerSchema(BaseSchema):
Field('name', VarChar(100), nullable=False),
Field('email', VarChar(100), nullable=False, unique=True),
]

register_schema(OwnerSchema)
```

#### Step 1b: Define a Schema for the `Bike`
Expand All @@ -45,6 +44,7 @@ This step creates the schema for the `Bike`. We use a `ForeignKeyField` to creat
```python
from polynom.schema.field import ForeignKeyField

@polynom_schema
class BikeSchema(BaseSchema):
namespace_name = 'vehicles'
entity_name = 'Bike'
Expand All @@ -58,8 +58,6 @@ class BikeSchema(BaseSchema):
ForeignKeyField('owner_id', referenced_schema=OwnerSchema),
# example for specific field: ForeignKeyField('owner_id', referenced_schema=OwnerSchema, referenced_db_field_name='name'),
]

register_schema(BikeSchema)
```

### Object Relationships
Expand Down Expand Up @@ -135,4 +133,42 @@ class TeamCyclistAssocSchema(BaseSchema):
]
```

Note that PolyNOM does not automatically create assocoation entities for many-to-many relationships.
## Circular Dependencies

Consider a bidirectional relationship between two models, `Author` and `Book`. Model `Author` must define a relationship to `Book`, and `Book` must define a relationship back to `Author`. However, at the time `Author` is being defined, the `Book` class might not yet be declared. This results in a circular dependency issue if direct class references are used.
To circumvent this limitation, model classes can also be specified using their **fully qualified name** (FQN) as a string, instead of a direct class reference. The FQN follows Python's standard module path syntax, such as:

```
"myapp.models.author.Author"
```

All models intended to be used in this way must be registered using the `@polynom_model` decorator. The model can then be resolved lazily at runtime, avoiding import-time circular dependency errors.

### Example

```python
# myapp/models/author.py

from polynom.model import BaseModel, Relationship, polynom_model

@polynom_model
class Author(BaseModel):
books = Relationship(
target_model="myapp.models.book.Book", # FQN string reference
back_populates="author"
)
```

```python
# myapp/models/book.py

from polynom.model import BaseModel, Relationship, polynom_model
from myapp.models.author import Author # optional, needed only if directly referencing Author

@polynom_model
class Book(BaseModel):
author = Relationship(
target_model=Author,
back_populates="books"
)
```
2 changes: 1 addition & 1 deletion polynom/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from polynom.session import Session
from polynom.schema.schema_registry import _get_ordered_schemas, _to_dict
from polynom.statement import _SqlGenerator, Statement, get_generator_for_data_model
from polynom.model import FlexModel
from polynom.model.model import FlexModel
from polynom.reflection import ChangeLog

logger = logging.getLogger(__name__)
Expand Down
File renamed without changes.
26 changes: 26 additions & 0 deletions polynom/model/model_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@

import logging
from polynom.model.model import BaseModel

logger = logging.getLogger(__name__)

_registered_models_by_fqname = {}

def polynom_model(cls):
if not issubclass(cls, BaseModel):
raise TypeError(f"@polynom_model can only be applied to subclasses of BaseModel, got {cls}")

fq_name = f"{cls.__module__}.{cls.__name__}"
if fq_name in _registered_models_by_fqname:
logger.warning(f"Model '{fq_name}' is already registered. Overwriting the existing entry.")
else:
logger.debug(f"Registering model '{fq_name}'")

_registered_models_by_fqname[fq_name] = cls
return cls

def _get_model_by_fqname(fq_name: str) -> type[BaseModel] | None:
return _registered_models_by_fqname.get(fq_name)

def _get_registered_models() -> dict[str, type[BaseModel]]:
return dict(_registered_models_by_fqname)
42 changes: 22 additions & 20 deletions polynom/schema/relationship.py → polynom/model/relationship.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from polynom.model.model_registry import _get_model_by_fqname

class Relationship:
def __init__(self, target_model: type["BaseModel"] = None, back_populates: str = None, cascade: str = None):
self.target_model = target_model
def __init__(self, target_model: type["BaseModel"] | str, back_populates: str = None, cascade: str = None):
if target_model is None:
raise ValueError("target_model must be specified for Relationship")
self._target_model = target_model
self._back_populates = back_populates
self._internal_name = None
self._cascade = cascade or ""
Expand All @@ -12,9 +16,21 @@ def __set_name__(self, owner, name):
self._key = name
self._owner_class = owner

@property
def target_model(self):
# Resolve string to actual model class once
if isinstance(self._target_model, str):
model_cls = _get_model_by_fqname(self._target_model)
if model_cls is None:
raise ValueError(f"Model '{self._target_model}' not found in registry for relationship '{self._key}'")
self._target_model = model_cls
return self._target_model

def __set__(self, instance, value):
if value and not isinstance(value, self.target_model):
raise TypeError(f'Value must be of {type[self.target_model]} but is of {type[value]}')
tm = self.target_model # resolve first

if value and not isinstance(value, tm):
raise TypeError(f'Value must be of {tm} but is of {type(value)}')
old_value = getattr(instance, self._internal_name, None)
if old_value is value:
return
Expand All @@ -38,8 +54,8 @@ def __set__(self, instance, value):
if value and self._back_populates:
if not hasattr(value, self._back_populates):
raise AttributeError(
f"Backref attribute '{self._back_populates}' not found on {value.__class__.__name__}"
)
f"Backref attribute '{self._back_populates}' not found on {value.__class__.__name__}"
)
current_back = getattr(value, self._back_populates, None)
if isinstance(current_back, list):
if instance not in current_back:
Expand All @@ -51,21 +67,7 @@ def __set__(self, instance, value):
if hasattr(instance, "_session") and instance._session:
instance._session.add(value)

def get_target_model(self):
if not self._back_populates or not self._owner_class:
raise ValueError("Cannot infer target_model without 'back_populates' and 'owner_class' set")

from polynom.model import BaseModel
for model_cls in BaseModel.__subclasses__():
for attr_name, attr in vars(model_cls).items():
if isinstance(attr, Relationship):
if attr._back_populates == self._key:
return model_cls

raise ValueError(f"Could not infer target_model for relationship '{self._key}'")

def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self._internal_name, None)

2 changes: 1 addition & 1 deletion polynom/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

if TYPE_CHECKING:
from polynom.session import Session
from polynom.model import BaseModel
from polynom.model.model import BaseModel

logger = logging.getLogger(__name__)

Expand Down
12 changes: 7 additions & 5 deletions polynom/reflection.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from polynom.model import BaseModel
from polynom.schema.schema_registry import register_schema
from polynom.model.model import BaseModel
from polynom.schema.schema_registry import polynom_schema
from polynom.model.model_registry import polynom_model
from polynom.schema.schema import BaseSchema
from polynom.schema.field import Field
from polynom.schema.polytypes import Timestamp, Text, Json
import polynom.config as cfg

@polynom_schema
class ChangeLogSchema(BaseSchema):
entity_name = cfg.get(cfg.CHANGE_LOG_TABLE)
namespace_name = cfg.get(cfg.INTERNAL_NAMESPACE)
Expand All @@ -18,6 +20,7 @@ class ChangeLogSchema(BaseSchema):
Field('changes', Json(), nullable=False),
]

@polynom_model
class ChangeLog(BaseModel):
schema = ChangeLogSchema()

Expand All @@ -31,19 +34,18 @@ def __init__(self, app_uuid: str, modified_entry_id: str, modified_entity_namesp
self.date_of_change = date_of_change
self.changes = changes

@polynom_schema
class SchemaSnapshotSchema(BaseSchema):
entity_name = cfg.get(cfg.SNAPSHOT_TABLE)
namespace_name = cfg.get(cfg.INTERNAL_NAMESPACE)
fields = [
Field('snapshot', Json(), nullable=False),
]

@polynom_model
class SchemaSnapshot(BaseModel):
schema = SchemaSnapshotSchema()

def __init__(self, snapshot: dict, _entry_id = None):
super().__init__(_entry_id)
self.snapshot = snapshot

register_schema(ChangeLogSchema)
register_schema(SchemaSnapshotSchema)
10 changes: 4 additions & 6 deletions polynom/schema/schema_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@
_registered_schemas = set()
_sorted_schemas = None

def register_schema(schema):
logger.debug(f'Schema registered: {str(schema)}')
_registered_schemas.add(schema)

def _get_registered_schemas():
return _registered_schemas
def polynom_schema(cls):
logger.debug(f'Schema registered: {str(cls)}')
_registered_schemas.add(cls)
return cls

def _get_ordered_schemas():
global _sorted_schemas
Expand Down
4 changes: 2 additions & 2 deletions polynom/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
from datetime import datetime
from enum import Enum, auto
from dataclasses import dataclass, field
from polynom.model import BaseModel
from polynom.model.model import BaseModel
from polynom.reflection import ChangeLog
from polynom.schema.relationship import Relationship
from polynom.model.relationship import Relationship
from polynom.statement import _SqlGenerator, Statement

logger = logging.getLogger(__name__)
Expand Down
Loading