-
-
Notifications
You must be signed in to change notification settings - Fork 89
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Feature request] Allow for composite primary keys, including foreign key columns as part of the PK #137
Comments
Composite pks should be declared in Model Meta class, otherwise the fields would have to be aware of each other and pk creation would have to be delayed after all of them are parsed (i.e. pk columns can be first and last in model) what complicates things, and is unnecessary in most common one column pks. I'm not sure how to handle composite fks, as you can customize the db names of columns in foreign keys, and i think i don't really like the idea of creating multiple columns in the background, as with your sample there is really no way to tell apart normal and composite fks, maybe it should also be declared in Meta. After all this is how sql in general and sqlalchemy handles the composites (you don't declare it on field but as a separate constraint). In general I like the concept of composite pks/fks and thought about it before, if you feel like it feel free to issue a PR, but be "warned" that it's gonna be quite complicated, as pk is used in many places and a lot of refactor/change is needed. There is a reason why tortoise, peewee etc. don't support them, yet in long term I do want ormar to support this, so if you decide to tackle this issue I'm more than happy to help. |
Thanks for being interested in supporting this! I can try implementing this as a PR, but before getting started I would like to get you on board with the interface that this functionality would expose. Composite PK API: class A(ormar.Model):
class Meta:
database = database
metadata = metadata
constraints = [ormar.PrimaryKeyConstraint("id_1", "id_2")]
id_1: uuid.UUID = ormar.UUID() # Automatically part of the PK
id_2: int = ormar.Integer(primary_key=True) # Also part of the PK
col_3: int = ormar.Integer(primary_key=True) # Raises a ModelDefinitionError The Composite FK API: class B(ormar.Model):
class Meta:
database = database
metadata = metadata
constraints = [
ormar.PrimaryKeyConstraint("id", "a_id_1", "a_id_2"),
ormar.ForeignKeyConstraint(
"a", ["a_id_1", "a_id_2"], A, ["id_1", "id_2"],
),
]
id: int = ormar.Integer(primary_key=True)
a_id_1: uuid.UUID = ormar.UUID(primarky_key=True)
a_id_2: int = ormar.Integer() The T = TypeVar("T", bound=ormar.Model)
class ForeignKeyConstraint(object):
def __init__(
self,
name: str,
columns: List[str],
ref_model: T,
ref_columns: List[str],
):
# ... And it could be used like this: async def create_objects():
a = await A.objects.create()
b = await B.objects.create(a=a)
print(b.a.id_1)
# Note that the name defined in the foreign key constraint
# becomes a property of B. The name of the FK constraint could then also be used to do generate reverse relationships by appending an "s". Does an API like this make sense to @collerek or is there something that you would like to change? |
Composite PKI think it should be clear and clearly separated:
Especially that marking a field Note also that in class A(ormar.Model):
class Meta:
database = database
metadata = metadata
constraints = [ormar.PrimaryKeyConstraint("id_1", "id_2")]
id_1: uuid.UUID = ormar.UUID() # Automatically part of the PK
id_2: int = ormar.Integer(primary_key=True) # Also exception is raised, without primary_key=True added automatically
col_3: int = ormar.Integer(primary_key=True) # Raises a ModelDefinitionError Composite FKThere already is To keep names more or less unified I suggest: T = TypeVar("T", bound=ormar.Model)
class ForeignKeyConstraint(object):
def __init__(
self,
to: T, # changed name and order
columns: List[str],
related_columns: List[str], # changed name
name: str = None, # this one should be optional, `ormar` already generates names of constraints,
# need to add those new pk/fk constraints to the generation,
# it's needed i.e. for proper alembic migrations
):
# ... Both FK & PKNote that you can pass a In existing class A(ormar.Model):
class Meta:
database = database
metadata = metadata
constraints = [ormar.PrimaryKeyConstraint("my_pk_1", "my_pk_2")] # <- actual db names
id_1: uuid.UUID = ormar.UUID(name="my_pk_2") # Different db names passed
id_2: int = ormar.Integer(name="my_pk_1") Same with FK: class B(ormar.Model):
class Meta:
database = database
metadata = metadata
constraints = [
ormar.PrimaryKeyConstraint("special_pk_3", "a_id_1", "a_id_2"), # actual db names
ormar.ForeignKeyConstraint(
"a", ["a_id_1", "a_id_2"], A, ["my_pk_1", "my_pk_2"], # actual db names
),
]
id: int = ormar.Integer(primary_key=True, name="special_pk_3") # db name changed
a_id_1: uuid.UUID = ormar.UUID(primarky_key=True)
a_id_2: int = ormar.Integer() The Note that Each model has also helpers to translate names to aliases or aliases to names, by one or all of them at once, they are located in UsageI'm not sure I follow. async def create_objects():
a = await A.objects.create()
b = await B.objects.create(a=a)
print(b.a.id_1) |
Thank you for the detailed reply! All the points you raised make sense to me, i.e. only allowing either a What I wanted to show in the usage part was just that composite FK relations should be usable by their name, rather than the columns that they are defined over. So to allow I will go ahead and try to implement this then, though I'll have to familiarize myself a bit more with the codebase. |
Ah, I just got what you meant :D Yes, the relation should be registered with T = TypeVar("T", bound=ormar.Model)
class ForeignKeyConstraint(object):
def __init__(
self,
to: T,
columns: List[str],
related_columns: List[str], # changed name
name: str = None, # name of the relation, still optional, defaults to `Model.get_name(lower=True)` so if T=Truck, name=truck
db_name: str = None, # this one should be optional, `ormar` already generates names of constraints,
# need to add those new pk/fk constraints to the generation,
# it's needed i.e. for proper alembic migrations
):
# ... But note that Even if only pk value is returned from db I'm wondering if this should be allowed (access to columns of composite fk), since you can populate relation with pk value only and with dict: class A(ormar.Model)
....
a = ormar.ForeignKey(B)
b = B(id=1)
# all of them are equivalent
a = A(a=b) # pass related model
a = A(a=1) # pass related model pk value
a = A(a={"id": 1}) # pass related model dict So maybe in composites you can pass model or dict (cannot pass pk value anymore, I don't like the idea of passing list/tuple), and direct access to |
Ok, again that makes sense. The only thing to consider wrt. accessing and setting the FK columns are overlapping FKs. class A(ormar.Model):
class Meta:
database = database
metadata = metadata
id: uuid.UUID = ormar.UUID(primary_key=True)
class B(ormar.Model):
class Meta:
database = database
metadata = metadata
constraints = [ormar.PrimaryKeyConstraint("id", "a")] # Is this fine or would "a" have to be defined as a normal
# Column with a separate FK constraint on it?
id = ormar.Integer()
a = ormar.ForeignKey(A) # Should this be allowed? See comment above.
class C(ormar.Model):
class Meta:
database = database
metadata = metadata
constraints = [
ormar.PrimaryKeyConstraint("id", "a_id", "b_id),
ormar.ForeignKeyConstraint(A, ["a_id"], ["id"], "a"),
ormar.ForeignKeyConstraint(B, ["a_id", "b_id"], ["a", "id"], "b"),
]
id = ormar.Integer()
a_id = ormar.UUID()
b_id = ormar.Integer() In this case, setting the FK columns directly might make sense, i.e. |
The Also although pk should be either in Meta or in Fields, I think the fks can be in both. Especially that you want to allow making fks part of the other fks and pks. If other fk is part of the class C(ormar.Model):
class Meta:
database = database
metadata = metadata
constraints = [
ormar.PrimaryKeyConstraint("id", "a_id", "b_id),
ormar.ForeignKeyConstraint(B, ["a", "b_id"], ["a", "id"], "b"), # one fk consists of other fk and other fields
]
id = ormar.Integer()
a = ormar.ForeignKey(A) # fks can be both in field and Meta
b_id = ormar.Integer() Since Or maybe we should leave it up to users, we can always restrict the use of those columns later and there might be a use case we don't think about here. The points that matters are:
|
Wrt. allowing access to the underlying FK columns, what I thought of was to disallow direct column access and only allow setting these columns via the FK constraint's name. I.e. in the example, But I'm wondering whether it would make sense to make an exception for columns that are part of the PK or to handle them in the same way. That's because setting these columns to anything other than values corresponding to a valid FK relationship would anyway be rejected by the corresponding FK constraint, so these columns might as well only be set using the FK constraints' names, as suggested above. I.e. in the example this would mean that only the The most consistent approach wrt. Pydantic models if we would implement FKs that way would then probably be to not include columns that are part of FKs directly as you said but rather include the referenced models under their FK constraint's name. One more point we should clarify: |
Direct access to fieldsYeah, I was imprecise, by direct access I meant user direct access (so Note that So I think we talk about the same, when part of pk/fk is a relation you can access it like a relation, when other columns are part of pk, you can also set them and access them, but when other columns are part of fk you cannot access them and exception should be raised like Reverse relationsNote that registration of related Models is mutual -> Cause registration of relation and models is mutual, you also need to create the second side of the relationship (technically it happens in class A(ormar.Model):
class Meta:
database = database
metadata = metadata
id: uuid.UUID = ormar.UUID(primary_key=True)
# here is auto registered ForeignKey with default name bs (B.get_name()+'s') that allows access to
# Bs from A. Now this field also have to be ForeignKeyConstraint as B's pk is multi column.
class B(ormar.Model):
class Meta:
database = database
metadata = metadata
constraints = [ormar.PrimaryKeyConstraint("id", "a")]
id = ormar.Integer()
a = ormar.ForeignKey(A) So thinking about this the signature of T = TypeVar("T", bound=ormar.Model)
class ForeignKeyConstraint(object):
def __init__(
self,
to: T,
columns: List[str],
related_columns: List[str], # changed name
name: str = None, # name of the relation, still optional, defaults to `Model.get_name(lower=True)` so if T=Truck, name=truck
related_name: str = None, # optional, defaults to owner model name.lower() + 's' (so if declared on model 'Car' it's 'cars')
db_name: str = None, # this one should be optional, `ormar` already generates names of constraints,
# need to add those new pk/fk constraints to the generation,
# it's needed i.e. for proper alembic migrations
):
# ... Consistency with pydantic modelNote that underlying To keep the Since the fields need to be there for validation anyway it's cleaner (and probably faster) than overloading ManyToManySince you want to add composed pks, when you would declare many to many relation to such model, the There is also
|
These points make sense to me! I tried to cover all the points we discussed so far through tests and started PR #138 to get going with the implementation. So far it only has those tests and no implementation yet. |
@collerek Did we ever get to finally implementing composite keys in Ormar? |
So what happened with this feature request? What's a good work-around to mapping tables with composite primary keys? |
Has any more thought been given to this? We're stuck on version 0.11.2 because we need composite PKs. |
It would be quite useful to be able to use composite primary keys, i.e. primary keys consisting of multiple DB columns.
Supporting this feature would mean supporting composite foreign keys as well.
And ideally, in addition, composite primary keys should be able to also span foreign key columns.
Concretely, I would like to be able to define models as follows:
Usecase:
The reason for why I would like to use composite foreign keys is to combine the benefits of UUID and integer primary keys.
Using the example above, UUID PKs allow me to hide how many users are registered in my system, which would be visible with integer (auto-increment) keys.
But then, I would like to use integer keys for
UserItem
andUserItemStatus
that increment on a per-user basis. I want to avoid using UUID keys for those items for reasons of column size and non-sequential data-layout, but also avoid a single integer primary key column because that would reveal how many items exist in the system when the ids are sent over an API.Using integer keys that increment on a per-user basis is ideal here, but it requires to combine them with another column into the primary key, because they would not be unique otherwise.
Is this feature something you would like to support?
If yes, I could also try to implement this myself and open a PR.
SQLAlchemy has support for this feature: https://docs.sqlalchemy.org/en/14/core/constraints.html
An additional consideration:
The
UserItemStatus
model above has a foreign key relationuser_item
, but ideally it should also have a foreign key relationuser
sinceuser
is part of theuser_item
foreign key.So there should be a way to define a
user
foreign key relationship that uses the column already present in theuser_item
relationship.If you are interested in supporting this feature, I can think about how to define these two relationships in an efficient way.
The text was updated successfully, but these errors were encountered: